From 53b8dc3f9e070304f415f5c29cd6804cf7a1c816 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 13 May 2026 12:51:16 +1000 Subject: [PATCH 01/14] update dependencies --- pubspec.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index 6619c39..3f94b21 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,19 +12,19 @@ dependencies: sdk: flutter clock: ^1.1.2 - dart_jsonwebtoken: ^3.2.0 + dart_jsonwebtoken: ^3.4.0 fast_rsa: ^3.8.1 - http: ^1.3.0 - jose: ^0.3.5 + http: ^1.6.0 + jose: ^0.3.5+2 logging: ^1.3.0 openidconnect_web: ^1.0.26 pointycastle: ^4.0.0 - url_launcher: ^6.3.1 - uuid: ^4.5.1 + url_launcher: ^6.3.2 + uuid: ^4.5.3 web: ^1.1.1 dev_dependencies: - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 jwt_decoder: ^2.0.1 # Keep dependency checker quiet. solid_auth_example: From 5a3b4ee63cb90d2f90d924e49ff17464af3d012a Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 13 May 2026 21:27:57 +1000 Subject: [PATCH 02/14] initial implementation --- README.md | 230 +++--- README_old.md | 157 ++++ example/.metadata | 10 +- example/lib/components/Header.dart | 113 --- example/lib/main.dart | 216 ++++-- example/lib/models/Constants.dart | 53 -- example/lib/models/GetRdfData.dart | 171 ---- example/lib/models/Responsive.dart | 81 -- example/lib/models/SolidApi.dart | 148 ---- example/lib/screens/EditProfile.dart | 392 ---------- example/lib/screens/LoginScreen.dart | 294 ------- example/lib/screens/PrivateProfile.dart | 237 ------ example/lib/screens/PrivateScreen.dart | 78 -- example/lib/screens/ProfileInfo.dart | 203 ----- example/lib/screens/PublicProfile.dart | 183 ----- example/lib/screens/PublicScreen.dart | 60 -- lib/platform_info.dart | 76 -- lib/solid_auth.dart | 51 +- lib/solid_auth_client.dart | 349 --------- lib/solid_auth_issuer.dart | 90 --- lib/src/auth/solid_auth_manager.dart | 258 ++++++ lib/src/auth/solid_oidc_manager_factory.dart | 141 ++++ .../auth_manager/auth_manager_abstract.dart | 50 -- lib/src/auth_manager/auth_manager_stub.dart | 34 - lib/src/auth_manager/web_auth_manager.dart | 88 --- lib/src/dpop/dpop_key_manager.dart | 71 ++ lib/src/dpop/dpop_token_generator.dart | 116 +++ lib/src/models/solid_auth_data.dart | 49 ++ lib/src/models/solid_provider_metadata.dart | 54 ++ lib/src/openid/openid_client.dart | 7 - lib/src/openid/openid_client_browser.dart | 202 ----- lib/src/openid/openid_client_io.dart | 228 ------ lib/src/openid/src/http_util.dart | 138 ---- lib/src/openid/src/model.dart | 40 - lib/src/openid/src/model/claims.dart | 213 ----- lib/src/openid/src/model/metadata.dart | 256 ------ lib/src/openid/src/model/token.dart | 34 - lib/src/openid/src/model/token_response.dart | 70 -- lib/src/openid/src/openid.dart | 732 ------------------ lib/src/openid/src/openid_exception.dart | 105 --- lib/src/openid/src/scopes.dart | 72 -- lib/src/profile/profile_fetcher.dart | 195 +++++ lib/src/utils/solid_scopes.dart | 51 ++ lib/src/utils/webid_utils.dart | 159 ++++ pubspec.yaml | 49 +- pubspec_old.yaml | 39 + 46 files changed, 1608 insertions(+), 5035 deletions(-) create mode 100644 README_old.md delete mode 100644 example/lib/components/Header.dart delete mode 100644 example/lib/models/Constants.dart delete mode 100644 example/lib/models/GetRdfData.dart delete mode 100644 example/lib/models/Responsive.dart delete mode 100644 example/lib/models/SolidApi.dart delete mode 100644 example/lib/screens/EditProfile.dart delete mode 100644 example/lib/screens/LoginScreen.dart delete mode 100644 example/lib/screens/PrivateProfile.dart delete mode 100644 example/lib/screens/PrivateScreen.dart delete mode 100644 example/lib/screens/ProfileInfo.dart delete mode 100644 example/lib/screens/PublicProfile.dart delete mode 100644 example/lib/screens/PublicScreen.dart delete mode 100644 lib/platform_info.dart delete mode 100644 lib/solid_auth_client.dart delete mode 100644 lib/solid_auth_issuer.dart create mode 100644 lib/src/auth/solid_auth_manager.dart create mode 100644 lib/src/auth/solid_oidc_manager_factory.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/dpop/dpop_key_manager.dart create mode 100644 lib/src/dpop/dpop_token_generator.dart create mode 100644 lib/src/models/solid_auth_data.dart create mode 100644 lib/src/models/solid_provider_metadata.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 delete mode 100644 lib/src/openid/src/openid_exception.dart delete mode 100644 lib/src/openid/src/scopes.dart create mode 100644 lib/src/profile/profile_fetcher.dart create mode 100644 lib/src/utils/solid_scopes.dart create mode 100644 lib/src/utils/webid_utils.dart create mode 100644 pubspec_old.yaml diff --git a/README.md b/README.md index 0c113de..ba17f74 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,115 @@ - +--- -# Solid Auth +## Architecture overview -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. - -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. - -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. - -## Features - -* 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 - - - -## Usage - -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 on -[github](https://github.com/anusii/solid_auth/tree/main/example). - -### Authentication Example +``` +solid_auth (public API) +│ +├── SolidAuthManager ← main facade (replaces authenticate()) +│ ├── loginFromWebId() ← resolves issuer, then logs in +│ ├── login() ← direct login given issuer URI +│ ├── currentAuthData ← typed SolidAuthData (not a raw Map) +│ ├── authChanges ← Stream (like Firebase Auth) +│ └── logout() / dispose() +│ +├── SolidOidcManagerFactory ← wires SolidOidcConfig → OidcUserManager +│ └── create() +│ +├── DpopTokenGenerator ← DPoP proof JWT generation (unchanged logic) +│ ├── generateForRequest() ← new: auto-fetches key from DpopKeyManager +│ └── generate() ← legacy-compatible static method +│ +├── DpopKeyManager ← RSA key-pair lifecycle +├── ProfileFetcher ← replaces fetchProfileData() +│ └── fetchProfile() → SolidProfile +│ +└── WebIdUtils ← replaces getIssuer() + ├── getIssuer() + └── getProviderMetadata() → SolidProviderMetadata +``` -```dart -import 'package:solid_auth/solid_auth.dart'; -import 'package:jwt_decoder/jwt_decoder.dart'; +### Dependency map -// Example WebID -String _myWebId = 'https://charlieb.solidcommunity.net/profile/card#me'; +``` +solid_auth + └── package:oidc (OidcUserManager, OidcUserManagerSettings, etc.) + └── oidc_core (OidcProviderMetadata, OidcToken, etc.) + └── oidc_default_store (secure token persistence) + └── dart_jsonwebtoken (DPoP JWT signing — kept) + └── fast_rsa (RSA key generation — kept) +``` -// Get issuer URI -String _issuerUri = await getIssuer(_myWebId); +The entire forked `openid_client` code is **removed**. All OIDC discovery, +PKCE, token exchange and refresh is delegated to `package:oidc`. -// Define scopes. Also possible scopes -> webid, email, api -final List _scopes = [ - 'openid', - 'profile', - 'offline_access', -]; +--- -// Authentication process for the POD issuer -var authData = await authenticate(Uri.parse(_issuerUri), _scopes); +## Migration guide — 0.1.x → 0.2.x -// Decode access token to recheck the WebID -String accessToken = authData['accessToken']; -Map decodedToken = JwtDecoder.decode(accessToken); -String webId = decodedToken['webid']; +| Old (0.1.x) | New (0.2.x) | +|------------------------------------------------------|----------------------------------------------------| +| `String issuer = await getIssuer(webId)` | `WebIdUtils.getIssuer(webId)` (same signature) | +| `var data = await authenticate(issuerUri, scopes)` | `SolidAuthManager.loginFromWebId(webId)` returns `SolidAuthData` | +| `data['accessToken']` | `authData.accessToken` | +| `data['idToken']` | `authData.idToken` | +| `genDpopToken(url, keyPair, jwk, method)` | `DpopTokenGenerator.generate(...)` (same params) | +| `fetchProfileData(webId)` | `ProfileFetcher().fetchProfile(webId)` | -``` +--- -### Accessing Public Data Example +## Quick start ```dart import 'package:solid_auth/solid_auth.dart'; -// Example WebID -String _myWebId = 'https://charlieb.solidcommunity.net/profile/card#me'; - -// Get issuer URI -Future profilePage = await fetchProfileData(_myWebId); - +// 1. Create the manager (once, at app level) +final auth = SolidAuthManager( + config: SolidOidcConfig( + clientId: 'my_client_id', + redirectUri: Uri.parse('com.example.app://callback'), + scopes: SolidScopes.defaultScopes, // includes webid automatically + ), +); + +// 2. Login — resolves issuer from WebID, then runs Authorization Code + PKCE +final authData = await auth.loginFromWebId( + 'https://charlieb.solidcommunity.net/profile/card#me', +); +print(authData.webId); // https://charlieb.solidcommunity.net/profile/card#me +print(authData.accessToken); + +// 3. Generate a DPoP proof for a resource request +final dpop = await DpopTokenGenerator.generateForRequest( + endpointUrl: 'https://charlieb.solidcommunity.net/private/notes.ttl', + httpMethod: 'GET', + accessToken: authData.accessToken, +); +// Use in HTTP headers: +// 'Authorization': 'DPoP ${authData.accessToken}' +// 'DPoP': dpop + +// 4. Fetch public profile +final profile = await ProfileFetcher().fetchProfile(authData.webId); +print(profile.name); +print(profile.storage); + +// 5. Logout +await auth.logout(); ``` -### Generating DPoP Token Example +--- -```dart -import 'package:solid_auth/solid_auth.dart'; - -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) +## Platform setup -// Generate DPoP token -String dPopToken = genDpopToken(endPointUrl, rsaKeyPair, publicKeyJwk, httpMethod); +Platform-specific setup (Android `build.gradle`, iOS `Info.plist`, +web `redirect.html`, etc.) follows `package:oidc` requirements exactly. +See the [oidc Getting Started guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/). -``` - -## 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 - - - - - - - - - - - -``` +The old `callback.html` for web should be replaced by the +[`redirect.html`](https://github.com/Bdaya-Dev/oidc/blob/main/packages/oidc/example/web/redirect.html) +from `package:oidc`. diff --git a/README_old.md b/README_old.md new file mode 100644 index 0000000..0c113de --- /dev/null +++ b/README_old.md @@ -0,0 +1,157 @@ + + +# 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. + +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. + +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. + +## Features + +* 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 + + + +## Usage + +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 on +[github](https://github.com/anusii/solid_auth/tree/main/example). + +### Authentication Example + +```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'; + +// Get issuer URI +String _issuerUri = await getIssuer(_myWebId); + +// Define scopes. Also possible scopes -> webid, email, api +final List _scopes = [ + 'openid', + 'profile', + 'offline_access', +]; + +// Authentication process for the POD issuer +var authData = await authenticate(Uri.parse(_issuerUri), _scopes); + +// Decode access token to recheck the WebID +String accessToken = authData['accessToken']; +Map decodedToken = JwtDecoder.decode(accessToken); +String webId = decodedToken['webid']; + +``` + +### Accessing Public Data Example + +```dart +import 'package:solid_auth/solid_auth.dart'; + +// Example WebID +String _myWebId = 'https://charlieb.solidcommunity.net/profile/card#me'; + +// Get issuer URI +Future profilePage = await fetchProfileData(_myWebId); + +``` + +### Generating DPoP Token Example + +```dart +import 'package:solid_auth/solid_auth.dart'; + +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) + +// Generate DPoP token +String dPopToken = genDpopToken(endPointUrl, rsaKeyPair, publicKeyJwk, httpMethod); + +``` + +## 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 + + + + + + + + + + + +``` diff --git a/example/.metadata b/example/.metadata index 55420a5..9b9642c 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "8defaa71a77c16e8547abdbfad2053ce3a6e2d5b" + revision: "ff37bef603469fb030f2b72995ab929ccfc227f0" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b - base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - platform: windows - create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b - base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 # User provided section diff --git a/example/lib/components/Header.dart b/example/lib/components/Header.dart deleted file mode 100644 index 9f6b1d3..0000000 --- a/example/lib/components/Header.dart +++ /dev/null @@ -1,113 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.dart'; - -// Package imports: -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; - Header({ - Key? key, - required this.mainDrawer, - required this.logoutUrl, - }) : 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, - ), - ), - 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), - ], - ), - ), - ); - } -} diff --git a/example/lib/main.dart b/example/lib/main.dart index de042c1..2b5a6cc 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,57 +1,183 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. +// example/lib/main.dart // -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.dart'; +// Demonstrates the restructured solid_auth API using package:oidc. +// Mirrors the usage example from the old solid_auth README. -// Project imports: -import 'package:solid_auth_example/screens/LoginScreen.dart'; +import 'package:flutter/material.dart'; +import 'package:solid_auth/solid_auth.dart'; +import 'package:logging/logging.dart'; void main() { - runApp(MyApp()); + // Optional: configure logging for debugging. + Logger.root.level = Level.FINE; + Logger.root.onRecord.listen( + (r) => debugPrint('[${r.loggerName}] ${r.level.name}: ${r.message}')); + + runApp(const SolidAuthExampleApp()); } -class MyApp extends StatelessWidget { - // This widget is the root of the application. +class SolidAuthExampleApp extends StatelessWidget { + const SolidAuthExampleApp({super.key}); + @override Widget build(BuildContext context) { return MaterialApp( - debugShowCheckedModeBanner: false, - title: 'Flutter Solid Authentication', - theme: ThemeData(), - home: LoginScreen(), + title: 'Solid Auth Example', + home: const LoginPage(), ); } } + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + // ── 1. Create the manager once ───────────────────────────────────────────── + // + // SolidAuthManager wraps OidcUserManager. You typically hold this at the + // app or provider level (Riverpod, BLoC, etc.). + final _auth = SolidAuthManager( + config: SolidOidcConfig( + // clientId: 'my_solid_client', + + // // On mobile: a custom-scheme URI registered with the OS. + // // On web: the path to your redirect.html (see package:oidc docs). + // redirectUri: Uri.parse('com.example.solidapp://callback'), + + // postLogoutRedirectUri: Uri.parse('com.example.solidapp://callback'), + + clientId: + 'https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld', + + // On mobile: a custom-scheme URI registered with the OS. + // On web: the path to your redirect.html (see package:oidc docs). + redirectUri: Uri.parse('com.example.solid.auth.example://redirect'), + + postLogoutRedirectUri: Uri.parse( + 'com.example.solid.auth.example://logout'), //Uri.parse('${appUrlScheme}://logout'), + + // Solid-OIDC scopes — webid is always added automatically. + scopes: SolidScopes.defaultScopes, + ), + ); + + SolidAuthData? _authData; + String? _error; + bool _loading = false; + + // ── 2. Authenticate from a WebID ─────────────────────────────────────────── + + Future _login() async { + const webId = 'https://pods.solidcommunity.au/'; + + setState(() { + _loading = true; + _error = null; + }); + + try { + // getIssuer() + OidcUserManager.init() + loginAuthorizationCodeFlow() + // are all handled internally. + final authData = await _auth.loginFromWebId(webId); + print('here'); + print(authData); + + setState(() => _authData = authData); + } on SolidAuthException catch (e) { + setState(() => _error = e.message); + } finally { + setState(() => _loading = false); + } + } + + // ── 3. Generate a DPoP token for a resource request ─────────────────────── + + Future _fetchPrivateResource() async { + if (_authData == null) return; + + const resourceUrl = 'https://charlieb.solidcommunity.net/private/data.ttl'; + + final dpopToken = await DpopTokenGenerator.generateForRequest( + endpointUrl: resourceUrl, + httpMethod: 'GET', + accessToken: _authData!.accessToken, + ); + + // Use the token in the HTTP request: + // headers: { + // 'Authorization': 'DPoP ${_authData!.accessToken}', + // 'DPoP': dpopToken, + // } + debugPrint('DPoP token: $dpopToken'); + } + + // ── 4. Fetch public profile ──────────────────────────────────────────────── + + Future _fetchProfile() async { + final profile = await const ProfileFetcher().fetchProfile(_authData!.webId); + debugPrint('Name: ${profile.name}'); + debugPrint('Storage: ${profile.storage}'); + debugPrint('Issuer: ${profile.oidcIssuer}'); + } + + // ── 5. Logout ───────────────────────────────────────────────────────────── + + Future _logout() async { + await _auth.logout(); + setState(() => _authData = null); + } + + @override + void dispose() { + _auth.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Solid Auth Example')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_error != null) + Text('Error: $_error', style: const TextStyle(color: Colors.red)), + if (_authData != null) ...[ + Text('WebID: ${_authData!.webId}'), + Text('Issuer: ${_authData!.issuer}'), + Text('Expired: ${_authData!.isExpired}'), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _fetchPrivateResource, + child: const Text('Generate DPoP Token')), + ElevatedButton( + onPressed: _fetchProfile, child: const Text('Fetch Profile')), + ElevatedButton(onPressed: _logout, child: const Text('Logout')), + ] else + ElevatedButton( + onPressed: _loading ? null : _login, + child: _loading + ? const CircularProgressIndicator() + : const Text('Login with Solid'), + ), + ], + ), + ), + ); + } +} + +// ── Issuer-only flow (matching old authenticate() API) ───────────────────── +// +// If you already have the issuer URI (e.g. from your own discovery logic), +// you can bypass WebID resolution: +// +// final authData = await _auth.login( +// issuerUri: 'https://solidcommunity.net', +// ); diff --git a/example/lib/models/Constants.dart b/example/lib/models/Constants.dart deleted file mode 100644 index a14aebb..0000000 --- a/example/lib/models/Constants.dart +++ /dev/null @@ -1,53 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.dart'; - -// All the contants -const darkGold = Color(0xFFBE830E); -const brickRed = Color(0xFFD89E7A); -const lightGold = Color(0xFFDBBA78); -const exLightBlue = Color(0xFFD8ECF3); -const darkCopper = Color(0xFFBE4E0E); -const titleAsh = Color(0xFF30384D); -const backgroundWhite = Color(0xFFF5F6FC); -const lightGray = Color(0xFF8793B2); -const bgOffWhite = Color(0xFFF2F4FC); - -const kDefaultPadding = 20.0; - -double screenWidth(BuildContext context) => MediaQuery.of(context).size.width; -// double screenHeight(BuildContext context) => MediaQuery.of(context).size.height; diff --git a/example/lib/models/GetRdfData.dart b/example/lib/models/GetRdfData.dart deleted file mode 100644 index b44312a..0000000 --- a/example/lib/models/GetRdfData.dart +++ /dev/null @@ -1,171 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Class to read the turtle files and extract values from triples -class PodProfile { - String profileRdfStr = ''; - - PodProfile(String profileRdfStr) { - this.profileRdfStr = profileRdfStr; - } - - List divideRdfData(String profileRdfStr) { - List rdfDataList = []; - String vcardPrefix = ''; - String foafPrefix = ''; - - var profileDataList = profileRdfStr.split('\n'); - for (var i = 0; i < profileDataList.length; i++) { - String dataItem = profileDataList[i]; - if (dataItem.contains(';')) { - var itemList = dataItem.split(';'); - for (var j = 0; j < itemList.length; j++) { - String item = itemList[j]; - rdfDataList.add(item); - } - } else { - rdfDataList.add(dataItem); - } - - if (dataItem.contains('')) { - var itemList = dataItem.split(' '); - vcardPrefix = itemList[1]; - } - - if (dataItem.contains('')) { - var itemList = dataItem.split(' '); - foafPrefix = itemList[1]; - } - } - return [rdfDataList, vcardPrefix, foafPrefix]; - } - - List dividePrvRdfData() { - List rdfDataList = []; - final Map prefixList = {}; - - var profileDataList = profileRdfStr.split('\n'); - for (var i = 0; i < profileDataList.length; i++) { - String dataItem = profileDataList[i]; - if (dataItem.contains(';')) { - var itemList = dataItem.split(';'); - for (var j = 0; j < itemList.length; j++) { - String item = itemList[j]; - rdfDataList.add(item); - } - } else { - rdfDataList.add(dataItem); - } - - if (dataItem.contains('@prefix')) { - var itemList = dataItem.split(' '); - prefixList[itemList[1]] = itemList[2]; - } - } - return [rdfDataList, prefixList]; - } - - String getProfPicture() { - var rdfRes = divideRdfData(profileRdfStr); - List rdfDataList = rdfRes[0]; - String vcardPrefix = rdfRes[1]; - String foafPrefix = rdfRes[2]; - String pictureUrl = ''; - String optionalPictureUrl = ''; - for (var i = 0; i < rdfDataList.length; i++) { - String dataItem = rdfDataList[i]; - if (dataItem.contains(vcardPrefix + 'hasPhoto')) { - var itemList = dataItem.split('<'); - pictureUrl = itemList[1].replaceAll('>', ''); - } - if (dataItem.contains(foafPrefix + 'img')) { - var itemList = dataItem.split('<'); - optionalPictureUrl = itemList[1].replaceAll('>', ''); - } - } - if (pictureUrl.isEmpty & optionalPictureUrl.isNotEmpty) { - pictureUrl = optionalPictureUrl; - } - return pictureUrl; - } - - 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')) { - var itemList = dataItem.split('"'); - profName = itemList[1]; - } - } - if (profName.isEmpty) { - profName = 'John Doe'; - } - return profName; - } - - 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)) { - var itemList = dataItem.split('"'); - personalInfo = itemList[1]; - } - } - return personalInfo; - } - - 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)) { - var itemList = dataItem.split(':'); - personalInfo = itemList[2]; - } - } - return personalInfo; - } -} diff --git a/example/lib/models/Responsive.dart b/example/lib/models/Responsive.dart deleted file mode 100644 index 0594013..0000000 --- a/example/lib/models/Responsive.dart +++ /dev/null @@ -1,81 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.dart'; - -// Project imports: -import 'package:solid_auth_example/models/Constants.dart'; - -// Widget to setup respostive designs -class Responsive extends StatelessWidget { - final Widget mobile; - final Widget tablet; - final Widget desktop; - - const Responsive({ - Key? key, - required this.mobile, - required this.tablet, - required this.desktop, - }) : super(key: key); - - static bool isMobile(BuildContext context) => screenWidth(context) < 650; - - static bool isTablet(BuildContext context) => - screenWidth(context) < 1100 && screenWidth(context) >= 650; - - static bool isDesktop(BuildContext context) => screenWidth(context) >= 1100; - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - //If width is more than 1100 consider it as desktop - if (constraints.maxWidth >= 1100) { - return desktop; - } - //If width is in between 1100 and 650 consider it as tablet - else if (constraints.maxWidth >= 650) { - return tablet; - } - //If width is less than 650 consider it as mobile - else { - return mobile; - } - }, - ); - } -} diff --git a/example/lib/models/SolidApi.dart b/example/lib/models/SolidApi.dart deleted file mode 100644 index 5f8cfe8..0000000 --- a/example/lib/models/SolidApi.dart +++ /dev/null @@ -1,148 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Dart imports: -import 'dart:async'; - -// Package imports: -import 'package:http/http.dart' as http; - -// Get private profile information using access and dPoP tokens -Future fetchPrvProfile( - String profCardUrl, String accessToken, String dPopToken) async { - final profResponse = await http.get( - Uri.parse(profCardUrl), - headers: { - 'Accept': '*/*', - 'Authorization': 'DPoP $accessToken', - 'Connection': 'keep-alive', - 'DPoP': '$dPopToken', - }, - ); - - if (profResponse.statusCode == 200) { - // If the server did return a 200 OK response, - // then parse the JSON. - return profResponse.body; - } else { - // If the server did not return a 200 OK response, - // then throw an exception. - throw Exception('Failed to load profile data! Try again in a while.'); - } -} - -// Update profile information -Future updateProfile(String profCardUrl, String accessToken, - String dPopToken, String query) async { - 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, - }, - body: query, - ); - - if (editResponse.statusCode == 200 || editResponse.statusCode == 205) { - // If the server did return a 200 OK response, - // then parse the JSON. - return 'success'; - } else { - // If the server did not return a 200 OK response, - // then throw an exception. - throw Exception('Failed to write profile data! Try again in a while.'); - } -} - -// Generate Sparql query -String genSparqlQuery( - String action, String subject, String predicate, String object, - {String? prevObject, String? format}) { - String query = ''; - - switch (action) { - case "INSERT": - { - query = 'INSERT DATA {<$subject> <$predicate> "$object".};'; - } - break; - - case "DELETE": - { - query = 'DELETE DATA {<$subject> <$predicate> "$object".};'; - } - break; - - case "UPDATE": - { - query = - 'DELETE DATA {<$subject> <$predicate> "$prevObject".}; INSERT DATA {<$subject> <$predicate> "$object".};'; - } - break; - - case "UPDATE_LANG": - { - query = - 'DELETE DATA {<$subject> <$predicate> "$prevObject"@en.}; INSERT DATA {<$subject> <$predicate> "$object"@en.};'; - } - break; - - case "UPDATE_DATE": - { - query = - 'DELETE DATA {<$subject> <$predicate> "$prevObject"^^<$format>.}; ' + - 'INSERT DATA {<$subject> <$predicate> "$object"^^<$format>.};'; - } - break; - - case "READ": - { - query = "Invalid"; - } - break; - - default: - { - query = "Invalid"; - } - break; - } - - return query; -} diff --git a/example/lib/screens/EditProfile.dart b/example/lib/screens/EditProfile.dart deleted file mode 100644 index e38cdcc..0000000 --- a/example/lib/screens/EditProfile.dart +++ /dev/null @@ -1,392 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.dart'; - -// Package imports: -//import 'package:jwt_decoder/jwt_decoder.dart'; -import 'package:solid_auth/solid_auth.dart'; - -import 'package:solid_auth_example/components/Header.dart'; -// Project imports: -import 'package:solid_auth_example/models/Constants.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; - const EditProfile({ - Key? key, - required this.authData, - required this.webId, - required this.profData, - }) : super(key: key); - - @override - _EditProfileState createState() => _EditProfileState(); -} - -class _EditProfileState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); - - // Text editing controllers - late TextEditingController nameController; - late TextEditingController dobController; - late TextEditingController occController; - late TextEditingController orgController; - - @override - void initState() { - super.initState(); - nameController = TextEditingController(text: widget.profData['name']); - dobController = TextEditingController(text: widget.profData['dob']); - occController = TextEditingController(text: widget.profData['occ']); - orgController = TextEditingController(text: widget.profData['org']); - } - - @override - Widget build(BuildContext context) { - String logoutUrl = widget.authData['logoutUrl']; - - return Scaffold( - key: _scaffoldKey, - body: SafeArea( - child: Column( - children: [ - Header(mainDrawer: _scaffoldKey, logoutUrl: logoutUrl), - Divider(thickness: 1), - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.all(kDefaultPadding * 1.5), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - children: [ - Stack( - children: [ - Row( - children: [ - Icon(Icons.menu_book_rounded, - color: brickRed), - SizedBox(width: 10.0), - Text("Edit Profile Info", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700)), - ], - ), - ], - ), - SizedBox( - height: 50, - ), - createInputField( - "NAME", nameController, widget.profData['name']), - createInputDateField("DATE OF BIRTH", dobController, - widget.profData['dob']), - createInputField("OCCUPATION", occController, - widget.profData['occ']), - createInputField("ORGANISATION", orgController, - widget.profData['org']), - SizedBox( - height: 20, - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton( - onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PrivateScreen( - authData: widget.authData, - webId: widget.webId, - )), - ); - }, - style: OutlinedButton.styleFrom( - padding: - EdgeInsets.symmetric(horizontal: 40), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(20))), - child: Text( - "CANCEL", - style: TextStyle( - color: darkGold, - letterSpacing: 2.0, - fontSize: 15.0, - fontWeight: FontWeight.bold, - fontFamily: 'Poppins', - ), - )), - SizedBox( - width: 10, - ), - 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 - String profCardUrl = - widget.webId.replaceAll('#me', ''); - - // Generate DPoP token - String dPopToken = genDpopToken(profCardUrl, - rsaKeyPair, publicKeyJwk, 'PATCH'); - - List attrList = [ - 'name', - 'dob', - 'occ', - 'org' - ]; // Attribute list - Map predicateMap = { - 'name': 'fn', - 'dob': 'bday', - 'occ': 'role', - 'org': 'organization-name' - }; // predicate name list - int numOfUpdates = 0; - - // Loop through attribute list and check for changes - // if there are any update those - for (var i = 0; i < attrList.length; i++) { - String attr = attrList[i]; - String prevVal = ''; - String newVal = ''; - - switch (attr) { - case 'name': - { - prevVal = widget.profData['name']; - newVal = nameController.text; - } - break; - case 'dob': - { - prevVal = widget.profData['dob']; - newVal = dobController.text; - } - break; - case 'occ': - { - prevVal = widget.profData['occ']; - newVal = occController.text; - } - break; - case 'org': - { - prevVal = widget.profData['org']; - newVal = orgController.text; - } - break; - default: - { - print('Invalid attribute name'); - } - } - - // If the value in an attribute is changed - if ((prevVal != '' && newVal != '') && - (prevVal != newVal)) { - String updateQuery = ''; - - // Generate update query - if (attr == 'dob') { - updateQuery = genSparqlQuery( - 'UPDATE_DATE', - widget.webId, - 'http://www.w3.org/2006/vcard/ns#' + - predicateMap[attr], - newVal, - prevObject: prevVal, - format: - 'http://www.w3.org/2001/XMLSchema#date'); - } else { - updateQuery = genSparqlQuery( - 'UPDATE', - widget.webId, - 'http://www.w3.org/2006/vcard/ns#' + - predicateMap[attr], - newVal, - prevObject: prevVal); - } - - // Update profile using the generated query - String updateResponse = - await updateProfile( - profCardUrl, - accessToken, - dPopToken, - updateQuery); - numOfUpdates += 1; - assert(updateResponse == 'success'); - } - } - - print( - '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, - )), - ); - }, - style: ElevatedButton.styleFrom( - foregroundColor: darkGold, - backgroundColor: lightGold, // foreground - padding: - EdgeInsets.symmetric(horizontal: 50), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(20))), - child: Text( - "UPDATE", - style: TextStyle( - color: Colors.white, - letterSpacing: 2.0, - fontSize: 15.0, - fontWeight: FontWeight.bold, - ), - )), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - // Create input field for texual values - TextField createInputField( - String labelText, TextEditingController controller, String initValue, - {double rowHeight = 25.0}) { - return TextField( - controller: controller, - decoration: InputDecoration( - //contentPadding: EdgeInsets.only(top: 5), - //contentPadding: EdgeInsets.all(0.0), - isDense: true, - contentPadding: EdgeInsets.fromLTRB(0.0, rowHeight, 0.0, 5.0), - labelText: "$labelText", - labelStyle: TextStyle( - color: titleAsh, - letterSpacing: 2.0, - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - floatingLabelBehavior: FloatingLabelBehavior.always, - hintStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.black, - )), - ); - } - - // Create input field for date values - TextField createInputDateField( - String labelText, TextEditingController controller, String initValue) { - return TextField( - controller: controller, - readOnly: true, - decoration: InputDecoration( - isDense: true, - contentPadding: EdgeInsets.fromLTRB(0.0, 25.0, 0.0, 5.0), - labelText: "$labelText", - floatingLabelBehavior: FloatingLabelBehavior.always, - hintStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.black, - )), - onTap: () async { - var date = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(1900), - lastDate: DateTime(2100), - builder: (context, child) { - return Theme( - data: Theme.of(context).copyWith( - colorScheme: ColorScheme.light( - primary: lightGold, // header background color - onPrimary: Colors.white, // header text color - onSurface: darkCopper, // body text color - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: Colors.red, // button text color - ), - ), - ), - child: child!, - ); - }, - ); - controller.text = date.toString().substring(0, 10); - }, - ); - } -} diff --git a/example/lib/screens/LoginScreen.dart b/example/lib/screens/LoginScreen.dart deleted file mode 100644 index 6a3408d..0000000 --- a/example/lib/screens/LoginScreen.dart +++ /dev/null @@ -1,294 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.dart'; - -import 'package:jwt_decoder/jwt_decoder.dart'; -//import 'package:solid_auth_example/models/RestAPI.dart'; -//import 'package:solid_auth/solid_auth.dart'; -import 'package:solid_auth/solid_auth.dart'; -// Package imports: -import 'package:url_launcher/url_launcher.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'; - -// ignore: must_be_immutable -class LoginScreen extends StatelessWidget { - // Sample web ID to check the functionality - var webIdController = TextEditingController() - ..text = 'https://pods.solidcommunity.au/'; - - @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( - image: AssetImage('assets/images/background.jpg'), - 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', - 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', - 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', - ), - ), - )), - ], - ), - ], - ), - ), - ), - ), - )), - ], - ), - ))); - } - - // POD issuer registration page launch - launchIssuerReg(String _issuerUri) async { - var url = '$_issuerUri/register'; - - if (await canLaunchUrl(Uri.parse(url))) { - await launchUrl(Uri.parse(url)); - } else { - throw 'Could not launch $url'; - } - } - - // Create login row for SOLID POD issuer - Row createSolidLoginRow( - 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), - ), - ), - 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, - ), - ), - )), - SizedBox( - width: 15.0, - ), - Expanded( - child: TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.all(20), - backgroundColor: lightGold, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - 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, - )), - ); - } - }, - child: Text( - 'LOGIN', - style: TextStyle( - color: Colors.white, - letterSpacing: 2.0, - fontSize: 15.0, - fontWeight: FontWeight.bold, - fontFamily: 'Poppins', - ), - ), - ), - ), - ], - ); - } -} diff --git a/example/lib/screens/PrivateProfile.dart b/example/lib/screens/PrivateProfile.dart deleted file mode 100644 index 61f7813..0000000 --- a/example/lib/screens/PrivateProfile.dart +++ /dev/null @@ -1,237 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.dart'; - -import 'package:solid_auth/solid_auth.dart'; - -import 'package:solid_auth_example/components/Header.dart'; -// Project imports: -import 'package:solid_auth_example/models/Constants.dart'; -import 'package:solid_auth_example/models/GetRdfData.dart'; -import 'package:solid_auth_example/models/SolidApi.dart' as rest_api; -import 'package:solid_auth_example/screens/ProfileInfo.dart'; - -class PrivateProfile extends StatefulWidget { - final Map authData; // Authentication data - final String webId; // User WebId - - const PrivateProfile({Key? key, required this.authData, required this.webId}) - : super(key: key); - - @override - State createState() => _PrivateProfileState(); -} - -class _PrivateProfileState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); - - // Loading widget - Widget _loadingScreen() { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - new Container( - alignment: AlignmentDirectional.center, - decoration: new BoxDecoration( - color: backgroundWhite, - ), - child: new Container( - decoration: new BoxDecoration( - color: lightGold, - borderRadius: new BorderRadius.circular(25.0)), - width: 300.0, - height: 200.0, - alignment: AlignmentDirectional.center, - child: new Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - new Center( - child: new SizedBox( - height: 50.0, - width: 50.0, - child: new CircularProgressIndicator( - value: null, - color: backgroundWhite, - strokeWidth: 7.0, - ), - ), - ), - new Container( - margin: const EdgeInsets.only(top: 25.0), - child: new Center( - child: new Text( - "Loading.. Please wait!", - style: new TextStyle(fontSize: 20, color: Colors.white), - ), - ), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _loadedScreen( - Object profInfo, String webId, String logoutUrl, Map authData) { - // Read profile info from the turtle file - PodProfile podProfile = PodProfile(profInfo.toString()); - - String profPic = - podProfile.getProfPicture(); // Get the url for profile picture - String profName = podProfile.getProfName(); // Get name - String profDob = podProfile.getPersonalInfo('bday'); // Get birthday - String profOcc = podProfile.getPersonalInfo('role'); // Get occupation - String profOrg = - podProfile.getPersonalInfo('organization-name'); // Get organisation - String profCoun = podProfile.getPersonalInfo('country-name'); // Get country - // String profReg = podProfile.getPersonalInfo('region'); // Get state - // String profAddId = - // podProfile.getAddressId('hasAddress'); // Get hasAddress flag - - // Set up correct profile picture url - String picUrl = webId; - if (profPic.contains('http')) { - picUrl = profPic; - } else { - if (profPic != '') { - picUrl = picUrl.replaceAll('card#me', profPic); - } else { - picUrl = - 'https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg'; - } - } - - // Store profile data in a dictionary - Map profData = { - 'name': profName, - 'picUrl': picUrl, - 'dob': profDob, - 'occ': profOcc, - 'org': profOrg, - 'loc': profCoun, - }; - - // Load profile info screen - return Container( - color: Colors.white, - child: Column( - children: [ - Header(mainDrawer: _scaffoldKey, logoutUrl: logoutUrl), - Divider(thickness: 1), - Expanded( - child: SingleChildScrollView( - controller: ScrollController(), - padding: EdgeInsets.all(kDefaultPadding * 1.5), - child: ProfileInfo( - profData: profData, - profType: 'private', - webId: webId, - authData: authData)), - ) - ], - ), - ); - } - - @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 profCardUrl = webId.replaceAll('#me', ''); - String dPopToken = - genDpopToken(profCardUrl, rsaKeyPair, publicKeyJwk, 'GET'); - - return Scaffold( - key: _scaffoldKey, - // drawer: ConstrainedBox( - // constraints: BoxConstraints(maxWidth: 300), - // child: SideMenu(authData: authData, webId: webId) - // ), - // endDrawer: ConstrainedBox( - // constraints: BoxConstraints(maxWidth: 400), - // child: ListOfSurveys(authData: authData, webId: webId) - // ), - body: SafeArea( - child: FutureBuilder( - future: - rest_api.fetchPrvProfile(profCardUrl, accessToken, dPopToken), - builder: (context, snapshot) { - Widget returnVal; - if (snapshot.connectionState == ConnectionState.done) { - returnVal = - _loadedScreen(snapshot.data!, webId, logoutUrl, authData); - } else { - returnVal = _loadingScreen(); - } - return returnVal; - }), - - // Container( - // color: Colors.white, - // child: Column( - // children: [ - // Header(mainDrawer: _scaffoldKey), - // Divider(thickness: 1), - // Expanded( - // child: SingleChildScrollView( - // padding: EdgeInsets.all(kDefaultPadding*1.5), - // child: screenWidth(context) > 1250 ? - // ProfileDesktop(profName:'Anushka Vidanage') - // : ProfileMobile(profName:'Anushka Vidanage') - // ), - // ) - // ], - // ), - // ), - ), - ); - } -} diff --git a/example/lib/screens/PrivateScreen.dart b/example/lib/screens/PrivateScreen.dart deleted file mode 100644 index e71909f..0000000 --- a/example/lib/screens/PrivateScreen.dart +++ /dev/null @@ -1,78 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.dart'; - -import 'package:solid_auth_example/models/Constants.dart'; -// Project imports: -import 'package:solid_auth_example/models/Responsive.dart'; -import 'package:solid_auth_example/screens/PrivateProfile.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); - - @override - Widget build(BuildContext context) { - // Assign loading screen - var loadingScreen = PrivateProfile(authData: authData, webId: webId); - - // Setup Scaffold to be responsive - return Scaffold( - body: Responsive( - mobile: loadingScreen, - tablet: Row( - children: [ - Expanded( - flex: 10, - child: loadingScreen, - ), - ], - ), - desktop: Row( - children: [ - Expanded( - flex: screenWidth(context) < 1300 ? 10 : 8, - child: loadingScreen, - ), - ], - ), - )); - } -} diff --git a/example/lib/screens/ProfileInfo.dart b/example/lib/screens/ProfileInfo.dart deleted file mode 100644 index ff9c712..0000000 --- a/example/lib/screens/ProfileInfo.dart +++ /dev/null @@ -1,203 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.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); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - children: [ - Stack( - children: [ - Container( - width: 130, - height: 130, - decoration: BoxDecoration( - border: Border.all( - width: 4, - color: Theme.of(context).scaffoldBackgroundColor, - ), - boxShadow: [ - BoxShadow( - spreadRadius: 2, - blurRadius: 10, - color: Colors.black.withValues(alpha: 0.1), - offset: Offset(0, 10)) - ], - shape: BoxShape.circle, - image: DecorationImage( - fit: BoxFit.cover, - image: NetworkImage(profData['picUrl']))), - ), - if (profType == 'private') - Positioned( - bottom: 0, - right: 0, - child: Container( - height: 45, - width: 45, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - width: 3, - color: Theme.of(context).scaffoldBackgroundColor, - ), - color: darkCopper, - ), - child: IconButton( - icon: new Icon(Icons.edit), - color: Colors.white, - onPressed: () { - // Navigate to the profile edit function - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => EditProfile( - authData: authData!, - webId: webId!, - profData: profData, - )), - ); - }, - ), - )), - ], - ), - // Display profile data - SizedBox( - height: 50, - ), - profileMenuItem("BASIC INFORMATION"), - SizedBox( - height: 20, - ), - buildLabelRow('Name', profData['name']), - buildLabelRow('Birthday', profData['dob']), - buildLabelRow('Country', profData['loc']), - // - profileMenuItem("WORK"), - SizedBox( - height: 20, - ), - buildLabelRow('Occupation', profData['occ']), - buildLabelRow('Organisation', profData['org']), - // - ], - ), - ), - ], - ); - } - - // A menu item - Row profileMenuItem(String title) { - return Row(children: [ - Text( - title, - style: TextStyle( - color: lightGray, - letterSpacing: 2.0, - fontSize: 12.0, - fontWeight: FontWeight.bold, - ), - ), - Expanded( - child: new Container( - margin: const EdgeInsets.only(left: 10.0, right: 0.0), - child: Divider( - color: lightGray, - height: 36, - )), - ), - ]); - } - - // A profile info row - Column buildLabelRow(String labelName, String profName) { - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '$labelName:', - style: TextStyle( - color: titleAsh, - letterSpacing: 2.0, - fontSize: 14.0, - fontWeight: FontWeight.bold, - ), - ), - Text( - profName, - style: TextStyle( - color: Colors.grey[800], - letterSpacing: 2.0, - fontSize: 14.0, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - SizedBox( - height: 30, - ) - ], - ); - } -} diff --git a/example/lib/screens/PublicProfile.dart b/example/lib/screens/PublicProfile.dart deleted file mode 100644 index 1b91de4..0000000 --- a/example/lib/screens/PublicProfile.dart +++ /dev/null @@ -1,183 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.dart'; - -//import 'package:solid_auth_example/models/RestAPI.dart'; -import 'package:solid_auth/solid_auth.dart'; - -import 'package:solid_auth_example/components/Header.dart'; -// Project imports: -import 'package:solid_auth_example/models/Constants.dart'; -import 'package:solid_auth_example/models/GetRdfData.dart'; -import 'package:solid_auth_example/screens/ProfileInfo.dart'; - -class PublicProfile extends StatefulWidget { - final String webId; - - const PublicProfile({Key? key, required this.webId}) : super(key: key); - - @override - State createState() => _PublicProfileState(); -} - -class _PublicProfileState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); - - // Loading widget - Widget _loadingScreen() { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - new Container( - alignment: AlignmentDirectional.center, - decoration: new BoxDecoration( - color: backgroundWhite, - ), - child: new Container( - decoration: new BoxDecoration( - color: lightGold, - borderRadius: new BorderRadius.circular(25.0)), - width: 300.0, - height: 200.0, - alignment: AlignmentDirectional.center, - child: new Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - new Center( - child: new SizedBox( - height: 50.0, - width: 50.0, - child: new CircularProgressIndicator( - value: null, - color: backgroundWhite, - strokeWidth: 7.0, - ), - ), - ), - new Container( - margin: const EdgeInsets.only(top: 25.0), - child: new Center( - child: new Text( - "Loading.. Please wait!", - style: new TextStyle(fontSize: 20, color: Colors.white), - ), - ), - ), - ], - ), - ), - ), - ], - ); - } - - // Loaded screen - Widget _loadedScreen(Object profInfo, String webId) { - // Get profile information from the .ttl file - PodProfile podProfile = PodProfile(profInfo.toString()); - String profPic = podProfile.getProfPicture(); - String profName = podProfile.getProfName(); - String profDob = podProfile.getPersonalInfo('bday'); - String profOcc = podProfile.getPersonalInfo('role'); - String profOrg = podProfile.getPersonalInfo('organization-name'); - String profCoun = podProfile.getPersonalInfo('country-name'); - - // Set profile picture url (if any) - String picUrl = webId; - if (profPic.contains('http')) { - picUrl = profPic; - } else { - if (profPic != '') { - picUrl = picUrl.replaceAll('card#me', profPic); - } else { - // Dafault picture - picUrl = - 'https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg'; - } - } - - // Store profile info - Map profData = { - 'name': profName, - 'picUrl': picUrl, - 'dob': profDob, - 'occ': profOcc, - 'org': profOrg, - 'loc': profCoun, - }; - - return Container( - color: Colors.white, - child: Column( - children: [ - Header(mainDrawer: _scaffoldKey, logoutUrl: 'none'), - Divider(thickness: 1), - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.all(kDefaultPadding * 1.5), - child: ProfileInfo(profData: profData, profType: 'public')), - ) - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - String webId = widget.webId; - - return Scaffold( - key: _scaffoldKey, - body: SafeArea( - child: FutureBuilder( - future: fetchProfileData( - webId), // Get profile data (.ttl file) from the webId - builder: (context, snapshot) { - Widget returnVal; - if (snapshot.connectionState == ConnectionState.done) { - returnVal = _loadedScreen(snapshot.data!, webId); - } else { - returnVal = _loadingScreen(); - } - return returnVal; - }), - ), - ); - } -} diff --git a/example/lib/screens/PublicScreen.dart b/example/lib/screens/PublicScreen.dart deleted file mode 100644 index 31b248c..0000000 --- a/example/lib/screens/PublicScreen.dart +++ /dev/null @@ -1,60 +0,0 @@ -/// SolidPod library to support privacy first data store on Solid Servers -/// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: AUTHORS - -// Add the library directive as we have doc entries above. We publish the above -// meta doc lines in the docs. - -library; - -// Flutter imports: -import 'package:flutter/material.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; - - PublicScreen({Key? key, required this.webId}) : super(key: key); - - @override - Widget build(BuildContext context) { - // Navigate to public profile with a loading screen - var loadingScreen = PublicProfile(webId: webId); - return Scaffold( - body: Responsive( - mobile: loadingScreen, - tablet: loadingScreen, - desktop: loadingScreen, - )); - } -} diff --git a/lib/platform_info.dart b/lib/platform_info.dart deleted file mode 100644 index 9812cd9..0000000 --- a/lib/platform_info.dart +++ /dev/null @@ -1,76 +0,0 @@ -/// Platform information -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: Anushka Vidanage - -library; - -// Dart imports: -import 'dart:io'; - -import 'package:flutter/foundation.dart' show kIsWeb; - -// Class to return platform information -class PlatformInfo { - bool isDesktopOS() { - return Platform.isMacOS || Platform.isLinux || Platform.isWindows; - } - - bool isAppOS() { - return Platform.isIOS || Platform.isAndroid; - } - - bool isWeb() { - return kIsWeb; - } - - PlatformType getCurrentPlatformType() { - if (kIsWeb) { - return PlatformType.web; - } - if (Platform.isMacOS) { - return PlatformType.macOS; - } - if (Platform.isFuchsia) { - return PlatformType.fuchsia; - } - if (Platform.isLinux) { - return PlatformType.linux; - } - if (Platform.isWindows) { - return PlatformType.windows; - } - if (Platform.isIOS) { - return PlatformType.iOS; - } - if (Platform.isAndroid) { - return PlatformType.android; - } - return PlatformType.unknown; - } -} - -enum PlatformType { web, iOS, android, macOS, fuchsia, linux, windows, unknown } diff --git a/lib/solid_auth.dart b/lib/solid_auth.dart index 3e8721f..69d071b 100644 --- a/lib/solid_auth.dart +++ b/lib/solid_auth.dart @@ -1,32 +1,27 @@ -/// Support for flutter apps authenticating to a Solid server. +/// Solid Auth — Solid-OIDC authentication for Flutter, built on package:oidc. /// -/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// Main entry point. Import this file to access the public API: /// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: Anushka Vidanage +/// ```dart +/// import 'package:solid_auth/solid_auth.dart'; +/// ``` +library solid_auth; + +// Public models +export 'src/models/solid_auth_data.dart'; +export 'src/models/solid_provider_metadata.dart'; + +// Core auth facade — the primary API consumers interact with +export 'src/auth/solid_auth_manager.dart'; +export 'src/auth/solid_oidc_manager_factory.dart'; + +// DPoP token generation (unchanged from current solid_auth API) +export 'src/dpop/dpop_token_generator.dart'; +export 'src/dpop/dpop_key_manager.dart'; -library; +// POD profile access +export 'src/profile/profile_fetcher.dart'; -export 'solid_auth_client.dart'; -export 'solid_auth_issuer.dart'; +// Utilities +export 'src/utils/webid_utils.dart'; +export 'src/utils/solid_scopes.dart'; diff --git a/lib/solid_auth_client.dart b/lib/solid_auth_client.dart deleted file mode 100644 index f04bbb0..0000000 --- a/lib/solid_auth_client.dart +++ /dev/null @@ -1,349 +0,0 @@ -/// Solid client management. -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: Anushka Vidanage - -library; - -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/widgets.dart'; - -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; -import 'package:fast_rsa/fast_rsa.dart'; -import 'package:http/http.dart' as http; -import 'package:url_launcher/url_launcher.dart'; -import 'package:uuid/uuid.dart'; - -import 'package:solid_auth/platform_info.dart'; -import 'package:solid_auth/src/auth_manager/auth_manager_abstract.dart'; -import 'package:solid_auth/src/openid/openid_client.dart'; -import 'package:solid_auth/src/openid/openid_client_io.dart' as oidc_mobile; - -/// 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(); - -/// 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, - prompt: 'consent', - redirectMessage: - 'Authentication process completed. You can now close this window!', - ); - - /// 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(); - - if (!context.mounted) return {}; - - 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(const Duration(seconds: 4)); - - /// closing web view function does not work in Windows applications - if (currPlatform.isAppOS()) { - //closeWebView(); - closeInAppWebView(); - } - return true; -} diff --git a/lib/solid_auth_issuer.dart b/lib/solid_auth_issuer.dart deleted file mode 100644 index 8cbe46f..0000000 --- a/lib/solid_auth_issuer.dart +++ /dev/null @@ -1,90 +0,0 @@ -/// Solid issuer management. -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: Anushka Vidanage - -library; - -import 'dart:async'; - -import 'package:http/http.dart' as http; - -/// Get POD issuer URI -Future getIssuer(String textUrl) async { - String issuerUri = ''; - if (textUrl.contains('profile/card#me')) { - String pubProf = await fetchProfileData(textUrl); - issuerUri = getIssuerUri(pubProf); - } - - if (issuerUri == '') { - /// This reg expression works with localhost and other urls - RegExp exp = RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+(\.|\:)[\w\.]+'); - Iterable matches = exp.allMatches(textUrl); - for (var match in matches) { - issuerUri = textUrl.substring(match.start, match.end); - } - } - return issuerUri; -} - -/// 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.'); - } -} - -/// Read public profile RDF file and get the issuer URI -String getIssuerUri(String profileRdfStr) { - String issuerUri = ''; - var profileDataList = profileRdfStr.split('\n'); - for (var i = 0; i < profileDataList.length; i++) { - String dataItem = profileDataList[i]; - if (dataItem.contains(';')) { - var itemList = dataItem.split(';'); - for (var j = 0; j < itemList.length; j++) { - String item = itemList[j]; - if (item.contains('solid:oidcIssuer')) { - var issuerUriDivide = item.replaceAll(' ', '').split('<'); - issuerUri = issuerUriDivide[1].replaceAll('>', ''); - } - } - } - } - return issuerUri; -} diff --git a/lib/src/auth/solid_auth_manager.dart b/lib/src/auth/solid_auth_manager.dart new file mode 100644 index 0000000..ae885b3 --- /dev/null +++ b/lib/src/auth/solid_auth_manager.dart @@ -0,0 +1,258 @@ +import 'package:http/http.dart' as http; +import 'package:oidc/oidc.dart'; +import 'package:logging/logging.dart'; + +import '../models/solid_auth_data.dart'; +import '../models/solid_provider_metadata.dart'; +import '../utils/solid_scopes.dart'; +import '../utils/webid_utils.dart'; +import 'solid_oidc_manager_factory.dart'; + +final _log = Logger('solid_auth.SolidAuthManager'); + +/// High-level facade for Solid-OIDC authentication. +/// +/// This is the primary class consumers interact with. It replaces the old +/// free-standing `authenticate()` function with a stateful, lifecycle-aware +/// manager that correctly handles token refresh, logout, and user-change +/// streams. +/// +/// ## Quick start +/// +/// ```dart +/// final auth = SolidAuthManager( +/// config: SolidOidcConfig( +/// clientId: 'my_client_id', +/// redirectUri: Uri.parse('com.example.app://callback'), +/// ), +/// ); +/// +/// // Resolve the issuer from a WebID, then authenticate. +/// final data = await auth.loginFromWebId( +/// 'https://charlieb.solidcommunity.net/profile/card#me', +/// ); +/// print(data.webId); // https://charlieb.solidcommunity.net/profile/card#me +/// ``` +/// +/// ## Migration from solid_auth 0.1.x +/// +/// | Old API | New API | +/// |--------------------------------------|--------------------------------------------| +/// | `getIssuer(webId)` | `WebIdUtils.getIssuer(webId)` | +/// | `authenticate(issuerUri, scopes)` | `SolidAuthManager.loginFromWebId(webId)` | +/// | `authData['accessToken']` | `SolidAuthData.accessToken` | +/// | `authData['idToken']` | `SolidAuthData.idToken` | +/// | `genDpopToken(...)` | `DpopTokenGenerator.generate(...)` | +class SolidAuthManager { + SolidAuthManager({ + required this.config, + this.httpClient, + }); + + final SolidOidcConfig config; + final http.Client? httpClient; + + OidcUserManager? _oidcManager; + + /// The underlying [OidcUserManager] once initialised. + /// Exposed for callers that need fine-grained access to oidc internals. + OidcUserManager get oidcManager { + if (_oidcManager == null) { + throw StateError( + 'SolidAuthManager has not been initialised for an issuer yet. ' + 'Call loginFromWebId() or initForIssuer() first.', + ); + } + return _oidcManager!; + } + + // ── Issuer-aware login ──────────────────────────────────────────────────── + + /// Resolves the OIDC issuer from [webId], initialises the underlying + /// [OidcUserManager], then triggers the Authorization Code + PKCE flow. + /// + /// Returns a [SolidAuthData] with the tokens and extracted WebID on success. + Future loginFromWebId( + String webId, { + List? scopeOverride, + }) async { + _log.info('Starting Solid-OIDC login for WebID: $webId'); + + final issuerUri = await WebIdUtils.getIssuer(webId, httpClient: httpClient); + return login(issuerUri: issuerUri, scopeOverride: scopeOverride); + } + + /// Initialises for [issuerUri] and triggers the Authorization Code flow. + /// + /// Use this when you already know the issuer URI and don't have a WebID. + Future login({ + required String issuerUri, + List? scopeOverride, + }) async { + await initForIssuer(issuerUri); + + final effectiveConfig = + scopeOverride != null ? _configWithScopes(scopeOverride) : config; + + // Re-create the manager if scopes differ. + if (scopeOverride != null) { + _oidcManager = SolidOidcManagerFactory.create( + issuerUri: issuerUri, + config: effectiveConfig, + ); + await _oidcManager!.init(); + } + + _log.fine('Launching Authorization Code + PKCE flow'); + print('now1'); + final user = await _oidcManager!.loginAuthorizationCodeFlow(); + + if (user == null) { + throw const SolidAuthTokenException('Login cancelled or failed.'); + } + + print('there is a user '); + print(user); + + return _mapUserToAuthData(user, issuerUri); + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + /// Initialises the [OidcUserManager] for [issuerUri] without triggering + /// login. Useful for restoring a persisted session on app start: + /// + /// ```dart + /// await auth.initForIssuer('https://solidcommunity.net'); + /// if (auth.currentAuthData != null) { + /// // Session restored from store — user is already logged in. + /// } + /// ``` + Future initForIssuer(String issuerUri) async { + if (_oidcManager == null || + _oidcManager!.discoveryDocument?.issuer.toString() != issuerUri) { + _log.fine('Initialising OidcUserManager for issuer: $issuerUri'); + _oidcManager = SolidOidcManagerFactory.create( + issuerUri: issuerUri, + config: config, + ); + await _oidcManager!.init(); + _log.fine('OidcUserManager ready'); + } + } + + /// Logs out the user from the identity provider and clears local tokens. + Future logout() async { + _log.info('Logging out'); + await _oidcManager?.logout(); + } + + /// Clears local token state without contacting the identity provider. + Future forgetUser() async { + await _oidcManager?.forgetUser(); + } + + /// Disposes the underlying [OidcUserManager]. Call this when the auth + /// object is no longer needed (e.g. in a widget's `dispose()`). + Future dispose() async { + await _oidcManager?.dispose(); + _oidcManager = null; + } + + // ── Token access ────────────────────────────────────────────────────────── + + /// Returns the current authenticated user as [SolidAuthData], or null + /// if no session is active. + SolidAuthData? get currentAuthData { + final user = _oidcManager?.currentUser; + if (user == null) return null; + return _mapUserToAuthData( + user, + _oidcManager?.discoveryDocument?.issuer.toString() ?? '', + ); + } + + /// Stream of user-session changes, mirroring [OidcUserManager.userChanges]. + /// + /// Emits `null` on logout and a [SolidAuthData] on login / token refresh. + Stream get authChanges { + return oidcManager.userChanges().map( + (user) => user == null + ? null + : _mapUserToAuthData( + user, + oidcManager.discoveryDocument?.issuer.toString() ?? '', + ), + ); + } + + /// Manually triggers a token refresh. Returns the refreshed [SolidAuthData] + /// or null if no refresh token is available. + Future refreshToken() async { + final user = await _oidcManager?.refreshToken(); + if (user == null) return null; + return _mapUserToAuthData( + user, + _oidcManager?.discoveryDocument?.issuer.toString() ?? '', + ); + } + + // ── Internal helpers ─────────────────────────────────────────────────────── + + SolidAuthData _mapUserToAuthData(OidcUser user, String issuerUri) { + print('mapping user to auth data'); + final token = user.token; + final claims = user.aggregatedClaims ?? {}; + + print('here1'); + + final accessToken = token.accessToken; + final idToken = token.idToken ?? ''; + final refreshToken = token.refreshToken; + final webId = _extractWebId(claims) ?? user.uid ?? ''; + + print('here2'); + print(token.expiresIn); + + // Derive expiry: prefer explicit expiresAt, fall back to now + expires_in. + final expiresAt = DateTime.now().add(token.expiresIn!); + + print('here3'); + + return SolidAuthData( + accessToken: accessToken ?? '', + idToken: idToken, + refreshToken: refreshToken, + webId: webId, + issuer: issuerUri, + expiresAt: expiresAt, + rawClaims: claims, + ); + } + + /// Solid-OIDC stores the WebID in the `webid` claim of the ID token. + String? _extractWebId(Map claims) { + final webid = claims['webid']; + if (webid is String && webid.isNotEmpty) return webid; + // Fallback: some providers use `sub` as a WebID URI. + final sub = claims['sub']; + if (sub is String && sub.startsWith('http')) return sub; + return null; + } + + SolidOidcConfig _configWithScopes(List scopes) { + final effectiveScopes = scopes.contains(SolidScopes.webid) + ? scopes + : [...scopes, SolidScopes.webid]; + return SolidOidcConfig( + clientId: config.clientId, + redirectUri: config.redirectUri, + postLogoutRedirectUri: config.postLogoutRedirectUri, + scopes: effectiveScopes, + clientSecret: config.clientSecret, + httpClient: config.httpClient, + extraTokenParameters: config.extraTokenParameters, + extraAuthParameters: config.extraAuthParameters, + ); + } +} diff --git a/lib/src/auth/solid_oidc_manager_factory.dart b/lib/src/auth/solid_oidc_manager_factory.dart new file mode 100644 index 0000000..80782da --- /dev/null +++ b/lib/src/auth/solid_oidc_manager_factory.dart @@ -0,0 +1,141 @@ +import 'package:http/http.dart' as http; +import 'package:oidc/oidc.dart'; +import 'package:oidc_default_store/oidc_default_store.dart'; +import 'package:logging/logging.dart'; + +import '../models/solid_provider_metadata.dart'; +import '../utils/solid_scopes.dart'; + +final _log = Logger('solid_auth.SolidOidcManagerFactory'); + +/// Configuration for building an [OidcUserManager] targeted at a Solid POD. +class SolidOidcConfig { + const SolidOidcConfig({ + required this.clientId, + required this.redirectUri, + this.postLogoutRedirectUri, + this.scopes = SolidScopes.defaultScopes, + this.clientSecret, + this.httpClient, + this.extraTokenParameters, + this.extraAuthParameters, + }); + + /// Your registered client ID. For dynamic registration this is assigned + /// by the Solid server after registration. + final String clientId; + + /// The redirect URI registered with the identity provider. + /// On web this should be the `redirect.html` page URL. + final Uri redirectUri; + + /// Post-logout redirect URI (optional). + final Uri? postLogoutRedirectUri; + + /// Scopes to request. Defaults to [SolidScopes.defaultScopes] which + /// includes the mandatory `webid` scope. + final List scopes; + + /// Optional client secret for confidential clients. + /// Leave null for public clients (mobile / SPA). + final String? clientSecret; + + /// Custom HTTP client (useful for proxying or testing). + final http.Client? httpClient; + + /// Extra parameters sent with every token request. + final Map? extraTokenParameters; + + /// Extra parameters sent with every authorization request. + final Map? extraAuthParameters; +} + +/// Factory that constructs a fully configured [OidcUserManager] for +/// Solid-OIDC authentication. +/// +/// This is the key wiring point between `solid_auth` and `package:oidc`. +/// It handles: +/// - Solid-specific discovery document wrapping. +/// - Correct scope defaults (`webid` always included). +/// - DPoP-ready token hooks (wired in separately via [SolidDpopHook]). +/// - Platform-appropriate storage via [OidcDefaultStore]. +/// +/// Example: +/// ```dart +/// final manager = await SolidOidcManagerFactory.create( +/// issuerUri: 'https://solidcommunity.net', +/// config: SolidOidcConfig( +/// clientId: 'my_client_id', +/// redirectUri: Uri.parse('com.example.app://callback'), +/// ), +/// ); +/// await manager.init(); +/// ``` +abstract class SolidOidcManagerFactory { + SolidOidcManagerFactory._(); + + /// Creates an [OidcUserManager] pre-configured for Solid-OIDC. + /// + /// [metadata] is optional — pass it if you have already fetched the + /// discovery document to avoid an extra network round-trip. + static OidcUserManager create({ + required String issuerUri, + required SolidOidcConfig config, + SolidProviderMetadata? metadata, + }) { + _log.fine('Creating OidcUserManager for issuer: $issuerUri'); + + // Ensure webid scope is always present (Solid-OIDC requirement). + final scopes = _ensureWebIdScope(config.scopes); + + final clientAuth = config.clientSecret != null + ? OidcClientAuthentication.clientSecretPost( + clientId: config.clientId, + clientSecret: config.clientSecret!, + ) + : OidcClientAuthentication.none(clientId: config.clientId); + + final settings = OidcUserManagerSettings( + redirectUri: config.redirectUri, + postLogoutRedirectUri: config.postLogoutRedirectUri, + scope: scopes, + extraAuthenticationParameters: { + // Solid-OIDC requires PKCE; package:oidc uses it by default for + // the Authorization Code flow, so no extra wiring is needed. + ...?config.extraAuthParameters, + }, + extraTokenParameters: config.extraTokenParameters ?? {}, + ); + + if (metadata != null) { + // Use the pre-fetched discovery document to skip a network call. + return OidcUserManager( + discoveryDocument: metadata.oidcMetadata, + clientCredentials: clientAuth, + store: OidcDefaultStore(), + settings: settings, + httpClient: config.httpClient, + ); + } + + // Lazy path: let package:oidc fetch the discovery document on init(). + return OidcUserManager.lazy( + discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri( + Uri.parse(issuerUri), + ), + clientCredentials: clientAuth, + store: OidcDefaultStore(), + settings: settings, + httpClient: config.httpClient, + ); + } + + static List _ensureWebIdScope(List scopes) { + if (scopes.contains(SolidScopes.webid)) return scopes; + _log.warning( + 'webid scope missing from config — adding it automatically ' + '(required by Solid-OIDC spec).', + ); + return [...scopes, SolidScopes.webid]; + } +} 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 d880fb9..0000000 --- a/lib/src/auth_manager/auth_manager_abstract.dart +++ /dev/null @@ -1,50 +0,0 @@ -/// Auth Manager class. -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: Anushka Vidanage -library; - -import 'package:solid_auth/src/auth_manager/auth_manager_stub.dart' - if (dart.library.html) 'web_auth_manager.dart'; -// import just for the client class. Not used anywhere else. -import 'package:solid_auth/src/openid/src/openid.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 79f9433..0000000 --- a/lib/src/auth_manager/auth_manager_stub.dart +++ /dev/null @@ -1,34 +0,0 @@ -/// Get Auth Manager -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: Anushka Vidanage -library; - -import 'package:solid_auth/src/auth_manager/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 9ab1906..0000000 --- a/lib/src/auth_manager/web_auth_manager.dart +++ /dev/null @@ -1,88 +0,0 @@ -/// Web Auth Manager -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the MIT License (the "License"). -/// -/// License: https://choosealicense.com/licenses/mit/. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -/// -/// Authors: Anushka Vidanage -library; - -import 'package:openidconnect_web/openidconnect_web.dart'; -import 'package:web/web.dart' hide Client; - -import 'package:solid_auth/src/auth_manager/auth_manager_abstract.dart'; -import 'package:solid_auth/src/openid/openid_client_browser.dart'; - -late Window windowLoc; - -class WebAuthManager implements AuthManager { - WebAuthManager() { - windowLoc = window; - // storing something initially just to make sure it works. - // windowLoc.localStorage.setItem('MyKey', 'I am from web local storage'); - windowLoc.localStorage.setItem('MyKey', 'I am from web local storage'); - } - - @override - String getWebUrl() { - if (window.location.href.contains('#/')) { - return window.location.href.replaceAll('#/', 'callback.html'); - } else { - return ('${window.location.href}callback.html'); - } - } - - @override - Authenticator createAuthenticator( - Client client, - List scopes, - String dPopToken, - ) { - var authenticator = Authenticator( - client, - scopes: scopes, - popToken: dPopToken, - ); - return authenticator; - } - - @override - OpenIdConnectWeb getOidcWeb() { - OpenIdConnectWeb oidc = OpenIdConnectWeb(); - return oidc; - } - - @override - String getKeyValue(String key) { - // return windowLoc.localStorage.getItem(key) as String; - return windowLoc.localStorage.getItem(key)!; - } - - @override - userLogout(String logoutUrl) { - final child = window.open(logoutUrl, 'user_logout'); - child!.close(); - } -} - -AuthManager getAuthManager() => WebAuthManager(); diff --git a/lib/src/dpop/dpop_key_manager.dart b/lib/src/dpop/dpop_key_manager.dart new file mode 100644 index 0000000..3b969c2 --- /dev/null +++ b/lib/src/dpop/dpop_key_manager.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'package:fast_rsa/fast_rsa.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('solid_auth.DpopKeyManager'); + +/// Manages the RSA key pair used for generating DPoP proofs. +/// +/// DPoP (Demonstrating Proof-of-Possession) binds an access token to a +/// specific key pair so that the token cannot be replayed by another party. +/// +/// Reference: https://datatracker.ietf.org/doc/html/rfc9449 +/// +/// The key pair is generated once per session and cached. For long-running +/// apps you may want to rotate the key pair periodically. +class DpopKeyManager { + DpopKeyManager._({ + required this.keyPair, + required this.publicKeyJwk, + }); + + /// The RSA key pair — both private and public key in PEM format. + final KeyPair keyPair; + + /// The public key as a JSON Web Key (JWK) map, included in every DPoP proof + /// header under `"jwk"`. + final Map publicKeyJwk; + + static DpopKeyManager? _instance; + + /// Generates a new RSA-2048 key pair and returns a [DpopKeyManager]. + /// + /// The result is cached for the lifetime of the process. Call [rotate] to + /// generate a fresh pair (e.g. after a long idle period). + static Future getInstance() async { + if (_instance != null) return _instance!; + return rotate(); + } + + /// Generates a new key pair, replacing any cached instance. + static Future rotate() async { + _log.fine('Generating new RSA-2048 key pair for DPoP'); + final keyPair = await RSA.generate(2048); + final jwk = await _publicKeyToJwk(keyPair.publicKey); + _instance = DpopKeyManager._(keyPair: keyPair, publicKeyJwk: jwk); + _log.fine('DPoP key pair ready (kid: ${jwk['kid']})'); + return _instance!; + } + + /// Clears the cached key pair (e.g. on logout). + static void clear() { + _instance = null; + } + + // ── JWK conversion ───────────────────────────────────────────────────────── + + static Future> _publicKeyToJwk( + String publicKeyPem, + ) async { + // fast_rsa can export a public key as a PKCS#1 DER and we convert to JWK. + final jwkJson = await RSA.convertPublicKeyToJWK(publicKeyPem); + final jwk = jsonDecode(jwkJson) as Map; + // Ensure the key type is set correctly for RS256. + return { + 'kty': 'RSA', + 'use': 'sig', + 'alg': 'RS256', + ...jwk, + }; + } +} diff --git a/lib/src/dpop/dpop_token_generator.dart b/lib/src/dpop/dpop_token_generator.dart new file mode 100644 index 0000000..ab5743d --- /dev/null +++ b/lib/src/dpop/dpop_token_generator.dart @@ -0,0 +1,116 @@ +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:fast_rsa/fast_rsa.dart'; +import 'package:uuid/uuid.dart'; +import 'package:logging/logging.dart'; + +import 'dpop_key_manager.dart'; + +final _log = Logger('solid_auth.DpopTokenGenerator'); +const _uuid = Uuid(); + +/// Generates DPoP (Demonstrating Proof-of-Possession) proof tokens. +/// +/// A DPoP proof is a short-lived JWT that binds an HTTP request to the +/// key pair associated with the current session. It must be sent alongside +/// the `Authorization: DPoP ` header. +/// +/// Reference: https://datatracker.ietf.org/doc/html/rfc9449 +/// +/// ## Migration from solid_auth 0.1.x +/// +/// The old free-standing function signature: +/// ```dart +/// String genDpopToken(endPointUrl, rsaKeyPair, publicKeyJwk, httpMethod) +/// ``` +/// is preserved as the static [generate] method, but the recommended +/// new approach is to use [generateForRequest] which fetches the key pair +/// from [DpopKeyManager] automatically. +abstract class DpopTokenGenerator { + DpopTokenGenerator._(); + + // ── New API ─────────────────────────────────────────────────────────────── + + /// Generates a DPoP proof for [httpMethod] on [endpointUrl], automatically + /// using the managed key pair from [DpopKeyManager]. + /// + /// Optionally binds the token to [accessToken] via the `ath` claim + /// (required by Solid-OIDC for resource server requests). + static Future generateForRequest({ + required String endpointUrl, + required String httpMethod, + String? accessToken, + }) async { + final keyManager = await DpopKeyManager.getInstance(); + return generate( + endpointUrl: endpointUrl, + keyPair: keyManager.keyPair, + publicKeyJwk: keyManager.publicKeyJwk, + httpMethod: httpMethod, + accessToken: accessToken, + ); + } + + // ── Legacy-compatible static API ───────────────────────────────────────── + + /// Generates a DPoP proof JWT. + /// + /// Matches the old `genDpopToken(endPointUrl, rsaKeyPair, publicKeyJwk, + /// httpMethod)` signature so existing call sites require minimal changes. + /// + /// Parameters: + /// - [endpointUrl] — the URL of the resource being accessed. + /// - [keyPair] — RSA key pair (from [DpopKeyManager] or supplied externally). + /// - [publicKeyJwk] — the public key in JWK format, embedded in the JWT header. + /// - [httpMethod] — the HTTP method (GET, POST, PUT, PATCH, DELETE, etc.). + /// - [accessToken] — when provided, the `ath` claim (SHA-256 of the token) + /// is added, binding the proof to the specific token. + static String generate({ + required String endpointUrl, + required KeyPair keyPair, + required Map publicKeyJwk, + required String httpMethod, + String? accessToken, + }) { + _log.fine('Generating DPoP proof: $httpMethod $endpointUrl'); + + final payload = { + 'jti': _uuid.v4(), // Unique token ID (replay protection) + 'htm': httpMethod.toUpperCase(), + 'htu': endpointUrl, + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }; + + // `ath` claim: base64url(sha256(ascii(access_token))) + // Required by Solid-OIDC when the DPoP proof accompanies a resource request. + if (accessToken != null && accessToken.isNotEmpty) { + payload['ath'] = _sha256Base64Url(accessToken); + } + + final jwt = JWT( + payload, + header: { + 'typ': 'dpop+jwt', + 'alg': 'RS256', + 'jwk': publicKeyJwk, + }, + ); + + return jwt.sign(RSAPrivateKey(keyPair.privateKey)); + } + + // ── Internal ─────────────────────────────────────────────────────────────── + + /// Returns the base64url-encoded SHA-256 hash of [input] (ASCII encoded). + /// Used for the `ath` claim per RFC 9449 §4.2. + static String _sha256Base64Url(String input) { + // dart_jsonwebtoken uses pointycastle internally; we use its hashing here. + // In a real implementation wire in a sha256 utility from pointycastle or + // crypto package. Shown here as a placeholder. + // ignore: todo + // TODO: replace with `crypto` package sha256 + base64Url encoding. + throw UnimplementedError( + 'SHA-256/base64url for ath claim — wire in the `crypto` package: ' + 'base64Url.encode(sha256.convert(ascii.encode(accessToken)).bytes)', + ); + } +} diff --git a/lib/src/models/solid_auth_data.dart b/lib/src/models/solid_auth_data.dart new file mode 100644 index 0000000..b29b670 --- /dev/null +++ b/lib/src/models/solid_auth_data.dart @@ -0,0 +1,49 @@ +/// Data returned after a successful Solid-OIDC authentication. +/// +/// Replaces the raw `Map` previously returned by +/// `authenticate()`, giving callers typed access to all token fields. +class SolidAuthData { + const SolidAuthData({ + required this.accessToken, + required this.idToken, + this.refreshToken, + required this.webId, + required this.issuer, + required this.expiresAt, + this.rawClaims = const {}, + }); + + /// The OAuth 2.0 access token. Used as a Bearer token in HTTP requests, + /// or as the `ath` claim in a DPoP proof. + final String accessToken; + + /// The OpenID Connect ID token (JWT). Contains the `webid` claim for + /// Solid-OIDC compliant providers. + final String idToken; + + /// The refresh token (if `offline_access` scope was requested). + final String? refreshToken; + + /// The authenticated user's WebID URI, extracted from the ID token. + final String webId; + + /// The issuer URI of the Solid identity provider. + final String issuer; + + /// Token expiry time (derived from the `exp` claim in the access token). + final DateTime expiresAt; + + /// Full decoded claims from the ID token for advanced use-cases. + final Map rawClaims; + + bool get isExpired => DateTime.now().isAfter(expiresAt); + + /// Convenience: returns auth headers for a plain Bearer request (no DPoP). + Map get bearerHeaders => { + 'Authorization': 'Bearer $accessToken', + }; + + @override + String toString() => + 'SolidAuthData(webId: $webId, issuer: $issuer, expired: $isExpired)'; +} diff --git a/lib/src/models/solid_provider_metadata.dart b/lib/src/models/solid_provider_metadata.dart new file mode 100644 index 0000000..cba6622 --- /dev/null +++ b/lib/src/models/solid_provider_metadata.dart @@ -0,0 +1,54 @@ +import 'package:oidc_core/oidc_core.dart'; + +/// Extends the standard OpenID Connect discovery document with +/// Solid-specific metadata endpoints. +/// +/// Solid servers MUST advertise a `solid_oidc_supported` claim and MAY +/// expose a `registration_endpoint` for dynamic client registration. +/// +/// See: https://solid.github.io/solid-oidc/#discovery +class SolidProviderMetadata { + const SolidProviderMetadata({ + required this.oidcMetadata, + this.solidOidcSupported, + this.registrationEndpoint, + this.storageEndpoint, + }); + + /// The underlying, standards-compliant OIDC provider metadata fetched + /// via `/.well-known/openid-configuration`. + final OidcProviderMetadata oidcMetadata; + + /// `solid_oidc_supported` — advertises Solid-OIDC conformance level. + /// Typically `"https://solidproject.org/TR/solid-oidc"`. + final String? solidOidcSupported; + + /// Dynamic client registration endpoint (RFC 7591). + /// Required for app registrations on Solid Community Server and similar. + final Uri? registrationEndpoint; + + /// The storage endpoint for this user's POD root (if advertised). + final Uri? storageEndpoint; + + /// Convenience accessors delegated to the wrapped metadata. + Uri get issuer => oidcMetadata.issuer!; + Uri get authorizationEndpoint => oidcMetadata.authorizationEndpoint!; + Uri get tokenEndpoint => oidcMetadata.tokenEndpoint!; + Uri? get userinfoEndpoint => oidcMetadata.userinfoEndpoint; + Uri? get jwksUri => oidcMetadata.jwksUri; + + /// Parses Solid-specific extra fields from a raw discovery document JSON map, + /// while delegating the standard fields to [OidcProviderMetadata]. + factory SolidProviderMetadata.fromJson(Map json) { + return SolidProviderMetadata( + oidcMetadata: OidcProviderMetadata.fromJson(json), + solidOidcSupported: json['solid_oidc_supported'] as String?, + registrationEndpoint: json['registration_endpoint'] != null + ? Uri.parse(json['registration_endpoint'] as String) + : null, + storageEndpoint: json['storage'] != null + ? Uri.parse(json['storage'] as String) + : null, + ); + } +} diff --git a/lib/src/openid/openid_client.dart b/lib/src/openid/openid_client.dart deleted file mode 100644 index aef0e30..0000000 --- a/lib/src/openid/openid_client.dart +++ /dev/null @@ -1,7 +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. - -// ignore: unnecessary_library_name -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 9ae71e8..0000000 --- a/lib/src/openid/openid_client_browser.dart +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -import 'dart:async'; -import 'dart:js_interop'; - -import 'package:web/web.dart' hide Credential, Client; - -import 'openid_client.dart'; - -export 'openid_client.dart'; - -/// A wrapper around [Flow] that handles the browser-specific parts of -/// authentication. -/// -/// The constructor takes a [Client] and a list of scopes. It then -/// creates a [Flow] and uses it to generate an authentication URI. -/// -/// The [authorize] method redirects the browser to the authentication URI. -/// -/// The [logout] method redirects the browser to the logout URI. -/// -/// The [credential] property returns a [Future] that completes with a -/// [Credential] after the user has signed in and the browser is redirected to -/// the app. Otherwise, it completes with `null`. -/// -/// The state is not persisted in the browser, so the user will have to sign in -/// again after a page refresh. If you want to persist the state, you'll have to -/// store and restore the credential yourself. You can listen to the -/// [Credential.onTokenChanged] event to be notified when the credential changes. -class Authenticator { - /// The [Flow] used for authentication. - /// - /// This will be a flow of type [FlowType.implicit]. - final Flow flow; - - /// A [Future] that completes with a [Credential] after the user has signed in - /// and the browser is redirected to the app. Otherwise, it completes with - /// `null`. - final Future credential; - - Authenticator._(this.flow) : credential = _credentialFromUri(flow); - - // Authenticator(Client client, - // {Iterable scopes = const [], String? device, String? prompt}) - // : this._(Flow.implicit(client, - // device: device, - // state: window.localStorage.getItem('openid_client:state'), - // prompt: prompt) - // ..scopes.addAll(scopes) - // ..redirectUri = Uri.parse(window.location.href).removeFragment()); - - // With PKCE flow - Authenticator( - Client client, { - Iterable scopes = const [], - popToken = '', - }) : this._( - Flow.authorizationCodeWithPKCE( - client, - state: window.localStorage.getItem('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, - ); - - /// Redirects the browser to the authentication URI. - void authorize() { - _forgetCredentials(); - window.localStorage.setItem('openid_client:state', flow.state); - window.location.href = flow.authenticationUri.toString(); - } - - /// Redirects the browser to the logout URI. - 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.removeItem('openid_client:state'); - window.localStorage.removeItem('openid_client:auth'); - } - - static Future _credentialFromUri(Flow flow) async { - var uri = Uri.parse(window.location.href); - var iframe = uri.queryParameters['iframe'] != null; - uri = Uri(query: uri.fragment); - var q = uri.queryParameters; - if (q.containsKey('access_token') || - q.containsKey('code') || - q.containsKey('id_token')) { - window.history.replaceState( - ''.toJS, - '', - Uri.parse(window.location.href).removeFragment().toString(), - ); - window.localStorage.removeItem('openid_client:state'); - - var c = await flow.callback(q.cast()); - if (iframe) window.parent!.postMessage(c.response?.toJSBox, '*'.toJS); - return c; - } - return null; - } - - /// Tries to refresh the access token silently in a hidden iframe. - /// - /// The implicit flow does not support refresh tokens. This method uses a - /// hidden iframe to try to get a new access token without the user having to - /// sign in again. It returns a [Future] that completes with a [Credential] - /// when the iframe receives a response from the authorization server. The - /// future will timeout after [timeout] if the iframe does not receive a - /// response. - Future trySilentRefresh({ - Duration timeout = const Duration(seconds: 20), - }) async { - var iframe = HTMLIFrameElement(); - var url = flow.authenticationUri; - window.localStorage.setItem('openid_client:state', flow.state); - iframe.src = url - .replace( - queryParameters: { - ...url.queryParameters, - 'prompt': 'none', - 'redirect_uri': flow.redirectUri - .replace( - queryParameters: { - ...flow.redirectUri.queryParameters, - 'iframe': 'true', - }, - ) - .toString(), - }, - ) - .toString(); - iframe.style.display = 'none'; - document.body!.append(iframe); - var event = await window.onMessage.first.timeout(timeout).whenComplete(() { - iframe.remove(); - }); - - var data = event.data?.dartify(); - if (data is Map) { - var current = await credential; - if (current == null) { - return flow.client.createCredential( - accessToken: data['access_token'], - expiresAt: data['expires_at'] == null - ? null - : DateTime.fromMillisecondsSinceEpoch( - int.parse(data['expires_at'].toString()) * 1000, - ), - refreshToken: data['refresh_token'], - expiresIn: data['expires_in'] == null - ? null - : Duration(seconds: int.parse(data['expires_in'].toString())), - tokenType: data['token_type'], - idToken: data['id_token'], - ); - } else { - return current..updateToken(data.cast()); - } - } else { - throw Exception('$data'); - } - } -} diff --git a/lib/src/openid/openid_client_io.dart b/lib/src/openid/openid_client_io.dart deleted file mode 100644 index ebe2525..0000000 --- a/lib/src/openid/openid_client_io.dart +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -// ignore: unnecessary_library_name -library openid_client.io; - -import 'dart:async'; -import 'dart:developer'; -import 'dart:io'; - -import 'openid_client.dart'; - -export 'openid_client.dart'; - -/// A wrapper around [Flow] that handles authentication in a non-web environment. -/// -/// This authenticator uses a local http server to listen for the redirect from -/// the authorization server. The server is started when the [authorize] method -/// is called and stopped when the [cancel] method is called or when the -/// authentication flow is completed. -/// -/// Some authorization servers might not allow to redirect to a local http -/// server. In that case, you should capture the authentication response in a -/// different way and pass it to the [processResult] method. -class Authenticator { - /// The [Flow] used for authentication. - final Flow flow; - - final Function(String url) urlLancher; - - /// The port used by the local http server. - final int port; - - String popToken; - - /// The html content to display when the authentication flow is completed. - /// - /// If this is null, the [redirectMessage] will be displayed instead. - final String? htmlPage; - - /// The text message to display when the authentication flow is completed. - /// - /// If [htmlPage] is not null, this will be ignored. - final String? redirectMessage; - - /// Creates an authenticator that uses the given [flow]. - Authenticator.fromFlow( - this.flow, { - Function(String url)? urlLancher, - String? redirectMessage, - this.htmlPage, - this.popToken = '', - }) : assert( - htmlPage != null ? redirectMessage == null : true, - 'You can only use one variable htmlPage (give entire html) or redirectMessage (only string message)', - ), - redirectMessage = redirectMessage ?? 'You can now close this window', - port = flow.redirectUri.port, - urlLancher = urlLancher ?? _runBrowser; - - /// Creates an authenticator that uses a [Flow.authorizationCodeWithPKCE] flow - /// when [redirectUri] is null and a [Flow.authorizationCode] flow otherwise. - Authenticator( - Client client, { - this.port = 4000, - this.urlLancher = _runBrowser, - this.popToken = '', - Iterable scopes = const [], - Uri? redirectUri, - String? redirectMessage, - String? prompt, - Map? additionalParameters, - this.htmlPage, - }) : assert( - htmlPage != null ? redirectMessage == null : true, - 'You can only use one variable htmlPage (give entire html) or redirectMessage (only string message)', - ), - redirectMessage = redirectMessage ?? 'You can now close this window', - flow = - redirectUri == null - ? Flow.authorizationCode( - client, - prompt: prompt, - additionalParameters: additionalParameters, - ) - : Flow.authorizationCodeWithPKCE( - client, - prompt: prompt, - additionalParameters: additionalParameters, - ) - ..scopes.addAll(scopes) - ..redirectUri = redirectUri ?? Uri.parse('http://localhost:$port/') - ..dPoPToken = popToken; - - /// Starts the authentication flow. - /// - /// This method will start a local http server and open the authorization - /// server's authentication page in a browser. - /// - /// The server will be stopped when the [cancel] method is called or when the - /// authentication flow is completed. - Future authorize() async { - var state = flow.authenticationUri.queryParameters['state']!; - - _requestsByState[state] = Completer(); - await _startServer(port, htmlPage, redirectMessage); - urlLancher(flow.authenticationUri.toString()); - - var response = await _requestsByState[state]!.future; - - return flow.callback(response); - } - - /// Cancels the authentication flow. - /// - /// This method will stop the local http server and complete the [authorize] - /// method with an error. - /// - /// This method should be called when the user cancels the authentication flow - /// in the browser. - 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, - String? htmlPage, - String? redirectMessage, - ) { - return _requestServers[port] ??= - (HttpServer.bind(InternetAddress.anyIPv4, port) - ..then((requestServer) async { - log('Server started at port $port'); - await for (var request in requestServer) { - request.response.statusCode = 200; - if (redirectMessage != null) { - request.response.headers.contentType = ContentType.html; - request.response.writeln( - htmlPage ?? - '' - '

$redirectMessage

' - '' - '', - ); - } - await request.response.close(); - var result = request.requestedUri.queryParameters; - - if (!result.containsKey('state')) continue; - await processResult(result); - } - - await _requestServers.remove(port); - })); - } - - /// Processes the result from an authentication flow. - /// - /// You can call this manually if you are redirected to the app by an external - /// browser. - /// - /// This method will complete the [authorize] method with the result of the - /// authentication flow. - 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) { - switch (Platform.operatingSystem) { - case 'linux': - Process.run('x-www-browser', [url]); - break; - case 'macos': - Process.run('open', [url]); - break; - case 'windows': - Process.run('explorer', [url]); - break; - default: - throw UnsupportedError( - 'Unsupported platform: ${Platform.operatingSystem}', - ); - } -} - -// 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 9202786..0000000 --- a/lib/src/openid/src/http_util.dart +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -import 'dart:async'; -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:logging/logging.dart'; - -import '../openid_client.dart'; -import 'openid_exception.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: () => const 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 7918b7c..0000000 --- a/lib/src/openid/src/model.dart +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -// ignore_for_file: unnecessary_library_name -// ignore_for_file: implementation_imports - -library openid.model; - -import 'package:clock/clock.dart'; -import 'package:jose/jose.dart'; -import 'package:jose/src/util.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 ec80431..0000000 --- a/lib/src/openid/src/model/claims.dart +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -part of '../model.dart'; - -mixin UserInfoMixin 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); -} - -abstract class UserInfo with UserInfoMixin { - factory UserInfo.fromJson(Map json) = _UserInfoImpl.fromJson; -} - -class _UserInfoImpl extends JsonObject with UserInfoMixin implements UserInfo { - _UserInfoImpl.fromJson(Map super.json) : super.from(); -} - -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 super.json) : super.from(); -} - -class OpenIdClaims extends JsonWebTokenClaims - with UserInfoMixin - implements 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!; - - // ignore: use_super_parameters - 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 e915fe1..0000000 --- a/lib/src/openid/src/model/metadata.dart +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -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'); - - // ignore: use_super_parameters - 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 fc8f5d1..0000000 --- a/lib/src/openid/src/model/token.dart +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -part of '../model.dart'; - -class IdToken extends JsonWebToken { - // ignore: use_super_parameters - 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 9aa1b68..0000000 --- a/lib/src/openid/src/model/token_response.dart +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -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 8f210a0..0000000 --- a/lib/src/openid/src/openid.dart +++ /dev/null @@ -1,732 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -// ignore_for_file: depend_on_referenced_packages - -// ignore: unnecessary_library_name -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'; -import 'openid_exception.dart'; -import 'scopes.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': supportedScopes, - }), - ), - 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 methods = - client.issuer.metadata.tokenEndpointAuthMethodsSupported ?? []; - 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'}; - - if (methods.contains('client_secret_basic')) { - var h = base64.encode( - '${client.clientId}:${client.clientSecret ?? ''}'.codeUnits, - ); - await http.post( - client.issuer.tokenEndpoint, - headers: {'authorization': 'Basic $h'}, - body: request, - client: client.httpClient, - ); - } else { - await http.post( - uri, - body: { - ...request, - 'client_id': client.clientId, - if (client.clientSecret != null) 'client_secret': client.clientSecret, - }, - client: client.httpClient, - ); - } - } - - /// 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 uri) async { - return http.get(uri, client: createHttpClient()); - } - - 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, - ); - - var grantType = _token.refreshToken != null - ? 'refresh_token' - : 'client_credentials'; - - ///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': grantType, - 'token_type': 'DPoP', - if (grantType == 'refresh_token') 'refresh_token': _token.refreshToken, - if (grantType == 'client_credentials') - 'scope': _token.toJson()['scope'], - // 'client_id': client.clientId, - // if (client.clientSecret != null) 'client_secret': client.clientSecret - }, - client: client.httpClient, - ); - - if (json['error'] != null) { - throw OpenIdException( - json['error'], - json['error_description'], - json['error_uri'], - ); - } - - updateToken(json); - return _token; - } - - /// Updates the token with the given [json] and notifies all listeners - /// of the new token. - /// - /// This method is used internally by [getTokenResponse], but can also be - /// used to update the token manually, e.g. when no refresh token is available - /// and the token is updated by other means. - void updateToken(Map json) { - _token = TokenResponse.fromJson({ - 'refresh_token': _token.refreshToken, - ...json, - }); - _onTokenChanged.add(_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 const OpenIdException.missingTokenEndpoint(); - } - return endpoint; - } -} - -enum FlowType { - implicit, - authorizationCode, - proofKeyForCodeExchange, - jwtBearer, - password, - clientCredentials, -} - -class Flow { - final FlowType type; - - final String? responseType; - - final Client client; - - final List scopes = []; - - final String state; - - final Map _additionalParameters; - - Uri redirectUri; - - String dPoPToken = ''; - - // Flow._(this.type, this.responseType, this.client, - // {String? state, - // String? codeVerifier, - // Map? additionalParameters, - // Uri? redirectUri, - // List scopes = const ['openid', 'profile', 'email']}) - // : 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.add(s); - // } - // } - - Flow._( - this.type, - this.responseType, - this.client, { - String? state, - String? codeVerifier, - 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 = codeVerifier ?? _randomString(50); - var challenge = base64Url - .encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))) - .replaceAll('=', ''); - _proofKeyForCodeExchange = { - 'code_verifier': verifier, - 'code_challenge': challenge, - }; - } - - /// Creates a new [Flow] for the password flow. - /// - /// This flow can be used for active authentication by highly-trusted - /// applications. Call [Flow.loginWithPassword] to authenticate a user with - /// their username and password. - Flow.password( - Client client, { - List scopes = const ['openid', 'profile', 'email'], - }) : this._(FlowType.password, '', client, scopes: scopes); - - Flow.authorizationCode( - Client client, { - String? state, - String? prompt, - String? accessType, - Uri? redirectUri, - Map? additionalParameters, - List scopes = const ['openid', 'profile', 'email'], - }) : this._( - FlowType.authorizationCode, - 'code', - client, - state: state, - additionalParameters: { - if (prompt != null) 'prompt': prompt, - if (accessType != null) 'access_type': accessType, - ...?additionalParameters, - }, - scopes: scopes, - redirectUri: redirectUri, - ); - - Flow.authorizationCodeWithPKCE( - Client client, { - String? state, - String? prompt, - List scopes = const ['openid', 'profile', 'email'], - String? codeVerifier, - Map? additionalParameters, - }) : this._( - FlowType.proofKeyForCodeExchange, - 'code', - client, - state: state, - scopes: scopes, - codeVerifier: codeVerifier, - additionalParameters: { - if (prompt != null) 'prompt': prompt, - ...?additionalParameters, - }, - ); - - Flow.implicit(Client client, {String? state, String? device, String? prompt}) - : this._( - FlowType.implicit, - ['token id_token', 'id_token token', 'id_token', 'token'].firstWhere( - (v) => client.issuer.metadata.responseTypesSupported.contains(v), - ), - client, - state: state, - scopes: [ - 'openid', - 'profile', - 'email', - if (device != null) 'offline_access', - ], - additionalParameters: { - if (device != null) 'device': device, - if (prompt != null) 'prompt': prompt, - }, - ); - - Flow.jwtBearer(Client client) : this._(FlowType.jwtBearer, null, client); - - Flow.clientCredentials(Client client, {List scopes = const []}) - : this._(FlowType.clientCredentials, 'token', client, scopes: scopes); - - 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(), - '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; - dynamic 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 (type == FlowType.clientCredentials) { - json = await http.post( - client.issuer.tokenEndpoint, - body: { - 'grant_type': 'client_credentials', - 'client_id': client.clientId, - if (client.clientSecret != null) 'client_secret': client.clientSecret, - 'scope': scopes.join(' '), - }, - 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'); - } - return TokenResponse.fromJson(json); - } - - /// Login with username and password - /// - /// Only allowed for [Flow.password] flows. - Future loginWithPassword({ - required String username, - required String password, - }) async { - if (type != FlowType.password) { - throw UnsupportedError('Flow is not password'); - } - var json = await http.post( - client.issuer.tokenEndpoint, - body: { - 'grant_type': 'password', - 'username': username, - 'password': password, - 'scope': scopes.join(' '), - 'client_id': client.clientId, - }, - client: client.httpClient, - ); - return Credential._(client, TokenResponse.fromJson(json), null); - } - - Future loginWithClientCredentials() async { - if (type != FlowType.clientCredentials) { - throw UnsupportedError('Flow is not clientCredentials'); - } - var json = await http.post( - client.issuer.tokenEndpoint, - body: { - 'grant_type': 'client_credentials', - 'client_id': client.clientId, - if (client.clientSecret != null) 'client_secret': client.clientSecret, - 'scope': scopes.join(' '), - }, - client: client.httpClient, - ); - return Credential._(client, TokenResponse.fromJson(json), null); - } - - 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(); -} diff --git a/lib/src/openid/src/openid_exception.dart b/lib/src/openid/src/openid_exception.dart deleted file mode 100644 index bd7f349..0000000 --- a/lib/src/openid/src/openid_exception.dart +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -// ignore_for_file: depend_on_referenced_packages - -/// An exception thrown when a response is received in the openid error format. -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/openid/src/scopes.dart b/lib/src/openid/src/scopes.dart deleted file mode 100644 index c79269c..0000000 --- a/lib/src/openid/src/scopes.dart +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) 2017, Rik Bellens. -// All rights reserved. - -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. - -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -// ignore_for_file: depend_on_referenced_packages - -const supportedScopes = [ - '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', -]; diff --git a/lib/src/profile/profile_fetcher.dart b/lib/src/profile/profile_fetcher.dart new file mode 100644 index 0000000..e3f4283 --- /dev/null +++ b/lib/src/profile/profile_fetcher.dart @@ -0,0 +1,195 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +final _log = Logger('solid_auth.ProfileFetcher'); + +/// Fetches and parses a Solid POD's public profile document from a WebID URI. +/// +/// Replaces the old `fetchProfileData(webId)` function. The profile document +/// is typically served as Turtle or JSON-LD. This class returns the raw body +/// alongside a lightly-parsed [SolidProfile] for the most common fields. +/// +/// For full RDF parsing, consumers should use a Turtle/JSON-LD library such +/// as `rdf_mapper` or `solid_flutter`. +class ProfileFetcher { + const ProfileFetcher({http.Client? httpClient}) + : _httpClient = httpClient; + + final http.Client? _httpClient; + + /// Fetches the profile document for [webId] and returns a [SolidProfile]. + /// + /// Negotiates `application/ld+json` first, then `text/turtle`. + Future fetchProfile(String webId) async { + _log.fine('Fetching profile for: $webId'); + + final client = _httpClient ?? http.Client(); + final ownClient = _httpClient == null; + + try { + final response = await client.get( + Uri.parse(webId), + headers: {'Accept': 'application/ld+json, text/turtle;q=0.9'}, + ); + + if (response.statusCode != 200) { + throw ProfileFetchException( + 'HTTP ${response.statusCode} fetching profile for $webId', + webId: webId, + statusCode: response.statusCode, + ); + } + + final contentType = response.headers['content-type'] ?? ''; + return SolidProfile._parse( + webId: webId, + body: response.body, + contentType: contentType, + ); + } finally { + if (ownClient) client.close(); + } + } +} + +// ── Model ────────────────────────────────────────────────────────────────── + +/// Lightweight representation of a Solid POD public profile. +/// +/// Contains the raw document body plus the fields most commonly needed +/// by Solid apps. For complete RDF access, parse [rawBody] directly. +class SolidProfile { + const SolidProfile({ + required this.webId, + required this.rawBody, + required this.contentType, + this.name, + this.storage, + this.oidcIssuer, + this.inbox, + }); + + /// The WebID URI this profile belongs to. + final String webId; + + /// The raw profile document body (Turtle or JSON-LD). + final String rawBody; + + /// The MIME type of the profile document. + final String contentType; + + /// `foaf:name` or `vcard:fn`, if found. + final String? name; + + /// `pim:storage` — the root container URI of the user's POD, if advertised. + final Uri? storage; + + /// `solid:oidcIssuer` — the identity provider URI, if advertised. + final Uri? oidcIssuer; + + /// `ldp:inbox` — the user's LDP inbox URI, if advertised. + final Uri? inbox; + + factory SolidProfile._parse({ + required String webId, + required String body, + required String contentType, + }) { + String? name; + Uri? storage; + Uri? oidcIssuer; + Uri? inbox; + + if (contentType.contains('json')) { + // JSON-LD path + try { + final doc = jsonDecode(body); + final nodes = doc is List ? doc : [doc]; + for (final node in nodes) { + if (node is Map) { + name ??= _jsonLdValue(node, 'http://xmlns.com/foaf/0.1/name') ?? + _jsonLdValue( + node, 'http://www.w3.org/2006/vcard/ns#fn'); + storage ??= _jsonLdUri( + node, 'http://www.w3.org/ns/pim/space#storage'); + oidcIssuer ??= _jsonLdUri( + node, 'http://www.w3.org/ns/solid/terms#oidcIssuer'); + inbox ??= + _jsonLdUri(node, 'http://www.w3.org/ns/ldp#inbox'); + } + } + } catch (e) { + _log.warning('Failed to parse JSON-LD profile: $e'); + } + } else { + // Naive Turtle scan (no full RDF parsing). + name = _turtleValue(body, r'foaf:name|vcard:fn'); + storage = _turtleUri(body, r'pim:storage|space:storage'); + oidcIssuer = _turtleUri(body, r'solid:oidcIssuer'); + inbox = _turtleUri(body, r'ldp:inbox'); + } + + return SolidProfile( + webId: webId, + rawBody: body, + contentType: contentType, + name: name, + storage: storage, + oidcIssuer: oidcIssuer, + inbox: inbox, + ); + } + + // ── JSON-LD helpers ──────────────────────────────────────────────────────── + + static String? _jsonLdValue(Map node, String predicate) { + final entry = node[predicate]; + if (entry is List && entry.isNotEmpty) { + final v = entry.first; + if (v is Map) return (v['@value'] ?? v['@id']) as String?; + } + return null; + } + + static Uri? _jsonLdUri(Map node, String predicate) { + final val = _jsonLdValue(node, predicate); + return val != null ? Uri.tryParse(val) : null; + } + + // ── Turtle helpers (naive regex — good enough for well-formed profiles) ─── + + static String? _turtleValue(String body, String predicatePattern) { + final pattern = RegExp( + '(?:$predicatePattern)\\s+"([^"]+)"', + caseSensitive: false, + ); + return pattern.firstMatch(body)?.group(1); + } + + static Uri? _turtleUri(String body, String predicatePattern) { + final pattern = RegExp( + '(?:$predicatePattern)\\s+<([^>]+)>', + caseSensitive: false, + ); + final match = pattern.firstMatch(body); + return match != null ? Uri.tryParse(match.group(1)!) : null; + } +} + +// ── Exception ───────────────────────────────────────────────────────────── + +class ProfileFetchException implements Exception { + const ProfileFetchException( + this.message, { + required this.webId, + this.statusCode, + }); + + final String message; + final String webId; + final int? statusCode; + + @override + String toString() => 'ProfileFetchException($webId): $message'; +} diff --git a/lib/src/utils/solid_scopes.dart b/lib/src/utils/solid_scopes.dart new file mode 100644 index 0000000..ac46ef3 --- /dev/null +++ b/lib/src/utils/solid_scopes.dart @@ -0,0 +1,51 @@ +/// Scope constants used in Solid-OIDC authentication requests. +/// +/// Standard OpenID Connect scopes plus the `webid` scope mandated by the +/// Solid-OIDC specification for identity binding. +/// +/// Reference: https://solid.github.io/solid-oidc/#scopes +abstract class SolidScopes { + // ── Standard OIDC scopes ────────────────────────────────────────────────── + + /// Required by all OIDC flows; enables the ID token. + static const String openid = 'openid'; + + /// Requests basic profile claims (name, picture, etc.). + static const String profile = 'profile'; + + /// Requests the user's email address. + static const String email = 'email'; + + /// Requests a refresh token so the session can be renewed silently. + static const String offlineAccess = 'offline_access'; + + // ── Solid-specific scopes ───────────────────────────────────────────────── + + /// **Solid-OIDC mandatory scope.** Requests that the `webid` claim be + /// included in the ID token. Without this the Solid identity binding + /// cannot be established. + static const String webid = 'webid'; + + // ── Convenience groupings ───────────────────────────────────────────────── + + /// Minimal scope set for read-only, session-less access to a Solid POD. + static const List minimal = [openid, webid]; + + /// Default scope set — mirrors the previous `solid_auth` defaults and + /// enables token refresh. + static const List defaultScopes = [ + openid, + profile, + offlineAccess, + webid, + ]; + + /// Full scope set including email. + static const List full = [ + openid, + profile, + email, + offlineAccess, + webid, + ]; +} diff --git a/lib/src/utils/webid_utils.dart b/lib/src/utils/webid_utils.dart new file mode 100644 index 0000000..bff9208 --- /dev/null +++ b/lib/src/utils/webid_utils.dart @@ -0,0 +1,159 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +import '../models/solid_provider_metadata.dart'; + +final _log = Logger('solid_auth.WebIdUtils'); + +/// Utility functions for working with Solid WebID URIs and deriving +/// the associated identity provider (issuer). +abstract class WebIdUtils { + WebIdUtils._(); + + // ── Issuer discovery ─────────────────────────────────────────────────────── + + /// Derives the issuer URI from a WebID by: + /// + /// 1. Fetching the WebID profile document (Turtle / JSON-LD). + /// 2. Looking for an `oidcIssuer` predicate + /// (`http://www.w3.org/ns/solid/terms#oidcIssuer`). + /// 3. Falling back to a heuristic (the WebID's origin) if no explicit + /// issuer is advertised. + /// + /// Throws a [SolidAuthException] if the issuer cannot be resolved. + static Future getIssuer( + String webId, { + http.Client? httpClient, + }) async { + _log.fine('Resolving issuer for WebID: $webId'); + final client = httpClient ?? http.Client(); + + try { + final profileUri = Uri.parse(webId); + final response = await client.get( + profileUri, + headers: {'Accept': 'application/ld+json, text/turtle;q=0.9'}, + ); + + if (response.statusCode != 200) { + throw SolidAuthDiscoveryException( + 'Failed to fetch WebID profile: HTTP ${response.statusCode}', + webId: webId, + ); + } + + final issuer = _extractIssuerFromProfile(response.body, profileUri); + if (issuer != null) { + _log.fine('Resolved issuer from profile: $issuer'); + return issuer; + } + + // Heuristic fallback: use origin as issuer + final fallback = profileUri.origin; + _log.warning( + 'No oidcIssuer found in profile for $webId — ' + 'falling back to origin: $fallback', + ); + return fallback; + } finally { + if (httpClient == null) client.close(); + } + } + + /// Fetches and parses the Solid-extended OpenID Connect discovery document + /// for the given [issuerUri]. + /// + /// This wraps `OidcEndpoints.getProviderMetadata` and adds Solid-specific + /// field parsing (e.g. `solid_oidc_supported`, `registration_endpoint`). + static Future getProviderMetadata( + String issuerUri, { + http.Client? httpClient, + }) async { + _log.fine('Fetching discovery document for: $issuerUri'); + final client = httpClient ?? http.Client(); + + try { + final discoveryUri = Uri.parse(issuerUri).replace( + path: '/.well-known/openid-configuration', + ); + + final response = await client.get( + discoveryUri, + headers: {'Accept': 'application/json'}, + ); + + if (response.statusCode != 200) { + throw SolidAuthDiscoveryException( + 'Discovery document request failed: HTTP ${response.statusCode}', + webId: issuerUri, + ); + } + + final json = jsonDecode(response.body) as Map; + return SolidProviderMetadata.fromJson(json); + } finally { + if (httpClient == null) client.close(); + } + } + + // ── Internal helpers ─────────────────────────────────────────────────────── + + /// Attempts to extract the `solid:oidcIssuer` value from a profile document. + /// Supports both JSON-LD and a naive Turtle scan (for the common pattern). + static String? _extractIssuerFromProfile(String body, Uri profileUri) { + // Try JSON-LD path first + try { + final decoded = jsonDecode(body); + final graphs = decoded is List ? decoded : [decoded]; + for (final node in graphs) { + if (node is Map) { + final issuerEntry = + node['http://www.w3.org/ns/solid/terms#oidcIssuer']; + if (issuerEntry is List && issuerEntry.isNotEmpty) { + final v = issuerEntry.first; + if (v is Map && v.containsKey('@id')) return v['@id'] as String; + if (v is Map && v.containsKey('@value')) { + return v['@value'] as String; + } + } + } + } + } catch (_) { + // Not JSON-LD — try Turtle heuristic below + } + + // Naive Turtle scan: look for `solid:oidcIssuer ` pattern + final turtlePattern = RegExp( + r'solid:oidcIssuer\s+<([^>]+)>', + caseSensitive: false, + ); + final match = turtlePattern.firstMatch(body); + return match?.group(1); + } +} + +// ── Exceptions ───────────────────────────────────────────────────────────── + +/// Base class for all solid_auth errors. +class SolidAuthException implements Exception { + const SolidAuthException(this.message); + final String message; + + @override + String toString() => 'SolidAuthException: $message'; +} + +/// Thrown when issuer/discovery-document resolution fails. +class SolidAuthDiscoveryException extends SolidAuthException { + const SolidAuthDiscoveryException(super.message, {required this.webId}); + final String webId; + + @override + String toString() => 'SolidAuthDiscoveryException($webId): $message'; +} + +/// Thrown when the OIDC token exchange or validation fails. +class SolidAuthTokenException extends SolidAuthException { + const SolidAuthTokenException(super.message); +} diff --git a/pubspec.yaml b/pubspec.yaml index 3f94b21..d0e4fa8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,37 +1,38 @@ name: solid_auth -description: Authenticate to a Solid server (solidproject.org). -version: 0.1.28 +description: Authenticate to a Solid POD server using Solid-OIDC, built on the certified oidc package. +version: 0.2.0 homepage: https://github.com/anusii/solid_auth +repository: https://github.com/anusii/solid_auth environment: - sdk: ">=3.2.3 <4.0.0" - flutter: ">=1.17.0" + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.10.0' dependencies: flutter: sdk: flutter - clock: ^1.1.2 - dart_jsonwebtoken: ^3.4.0 + # Core OIDC package (OpenID certified) replacing the forked openid_client + oidc: ^0.14.0 + oidc_core: ^0.16.0 + oidc_default_store: ^0.6.0 + + # JWT handling — kept for DPoP proof generation + dart_jsonwebtoken: ^3.2.0 + + # RSA key pair generation for DPoP fast_rsa: ^3.8.1 - http: ^1.6.0 - jose: ^0.3.5+2 - logging: ^1.3.0 - openidconnect_web: ^1.0.26 pointycastle: ^4.0.0 - url_launcher: ^6.3.2 - uuid: ^4.5.3 - web: ^1.1.1 + + # HTTP + http: ^1.3.0 + + # Utilities + logging: ^1.3.0 + uuid: ^4.5.1 dev_dependencies: - flutter_lints: ^6.0.0 - jwt_decoder: ^2.0.1 - # Keep dependency checker quiet. - solid_auth_example: - path: example - -flutter: - assets: - - packages/fast_rsa/web/assets/worker.js - - packages/fast_rsa/web/assets/wasm_exec.js - - packages/fast_rsa/web/assets/rsa.wasm + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + mocktail: ^1.0.0 diff --git a/pubspec_old.yaml b/pubspec_old.yaml new file mode 100644 index 0000000..2d73df5 --- /dev/null +++ b/pubspec_old.yaml @@ -0,0 +1,39 @@ +name: solid_auth +description: Authenticate to a Solid server (solidproject.org). +version: 0.1.28 +homepage: https://github.com/anusii/solid_auth + +environment: + sdk: ">=3.2.3 <4.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + + clock: ^1.1.2 + dart_jsonwebtoken: ^3.4.0 + fast_rsa: ^3.8.1 + http: ^1.6.0 + jose: ^0.3.5+2 + logging: ^1.3.0 + oidc: ^0.14.0+2 + oidc_default_store: ^0.6.0+2 + openidconnect_web: ^1.0.26 + pointycastle: ^4.0.0 + url_launcher: ^6.3.2 + uuid: ^4.5.3 + web: ^1.1.1 + +dev_dependencies: + flutter_lints: ^6.0.0 + jwt_decoder: ^2.0.1 + # Keep dependency checker quiet. + solid_auth_example: + path: example + +flutter: + assets: + - packages/fast_rsa/web/assets/worker.js + - packages/fast_rsa/web/assets/wasm_exec.js + - packages/fast_rsa/web/assets/rsa.wasm From b4b88f4e6e34291574686e782af7f566fe3f0720 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Fri, 15 May 2026 01:06:31 +1000 Subject: [PATCH 03/14] new files --- lib/src/new/dpop_key_manager(1).dart | 119 ++++++++++++++ lib/src/new/dpop_key_manager.dart | 119 ++++++++++++++ lib/src/new/dpop_token_generator.dart | 170 ++++++++++++++++++++ lib/src/new/solid_dpop_http_client.dart | 105 ++++++++++++ lib/src/new/solid_oidc_manager_factory.dart | 129 +++++++++++++++ 5 files changed, 642 insertions(+) create mode 100644 lib/src/new/dpop_key_manager(1).dart create mode 100644 lib/src/new/dpop_key_manager.dart create mode 100644 lib/src/new/dpop_token_generator.dart create mode 100644 lib/src/new/solid_dpop_http_client.dart create mode 100644 lib/src/new/solid_oidc_manager_factory.dart diff --git a/lib/src/new/dpop_key_manager(1).dart b/lib/src/new/dpop_key_manager(1).dart new file mode 100644 index 0000000..57cd227 --- /dev/null +++ b/lib/src/new/dpop_key_manager(1).dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:fast_rsa/fast_rsa.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('solid_auth.DpopKeyManager'); + +/// Manages the RSA key pair used for DPoP proofs. +/// +/// ## Why this must be created BEFORE authentication +/// +/// Solid-OIDC requires DPoP key binding at the **token endpoint** level +/// (RFC 9449 §5). The client MUST send a DPoP proof JWT as the `DPoP` +/// HTTP header on the token endpoint request. The OP then: +/// +/// 1. Validates the proof. +/// 2. Computes `jkt` = base64url(SHA-256(RFC 7638 JWK thumbprint)). +/// 3. Embeds `cnf: { jkt: "…" }` in the issued access token. +/// +/// A Resource Server verifying a `PATCH` / `GET` / etc. request later +/// checks that the DPoP proof was signed by the key whose thumbprint +/// matches `cnf.jkt` in the access token. +/// +/// If the token was issued WITHOUT a DPoP proof at token-request time +/// there is no `cnf` claim at all, and the RS returns: +/// +/// > "Expected object property cnf, got: [object Object]" +/// +/// **Fix**: generate the key pair here ONCE, inject it into every token +/// request via a `package:oidc` token hook, and reuse the same key pair +/// for all subsequent resource-level DPoP proofs. +class DpopKeyManager { + DpopKeyManager._({ + required this.keyPair, + required this.publicKeyJwk, + required this.jkt, + }); + + /// RSA-2048 key pair (PEM-encoded). + final KeyPair keyPair; + + /// Public key as a JWK map — embedded in every DPoP proof JWT header. + final Map publicKeyJwk; + + /// JWK thumbprint (RFC 7638, SHA-256, base64url, no padding). + /// + /// This is what the OP stores as `cnf.jkt` inside the access token. + /// Resource Servers use this value to confirm the proof was signed by + /// the same key that was presented at token-issuance time. + final String jkt; + + // ── Singleton ───────────────────────────────────────────────────────────── + + static DpopKeyManager? _instance; + + /// Returns the cached key manager, generating a fresh pair if none exists. + /// + /// Call **before** starting the auth flow so the key is ready when the + /// token-endpoint hook fires. + static Future getInstance() async { + return _instance ??= await _generate(); + } + + /// Generates a new RSA-2048 pair and replaces the cached instance. + /// + /// Use on logout or to rotate the DPoP binding key. + static Future rotate() async { + _instance = null; + return getInstance(); + } + + /// Clears the cached instance (call on logout). + static void clear() { + _instance = null; + } + + // ── Internal ────────────────────────────────────────────────────────────── + + static Future _generate() async { + _log.fine('Generating RSA-2048 DPoP key pair'); + final keyPair = await RSA.generate(2048); + final jwk = await _buildJwk(keyPair.publicKey); + final jkt = _computeJkt(jwk); + _log.fine('DPoP key pair ready — jkt: $jkt'); + return DpopKeyManager._(keyPair: keyPair, publicKeyJwk: jwk, jkt: jkt); + } + + /// Builds an RSA JWK from PEM. Only kty/n/e are strictly required for the + /// thumbprint; alg/use are added for RS compatibility. + static Future> _buildJwk(String publicKeyPem) async { + final jwkJson = await RSA.convertPublicKeyToJWK(publicKeyPem); + final raw = jsonDecode(jwkJson) as Map; + return { + 'kty': 'RSA', + 'use': 'sig', + 'alg': 'RS256', + if (raw['n'] != null) 'n': raw['n'], + if (raw['e'] != null) 'e': raw['e'], + }; + } + + /// RFC 7638 §3.2 — JWK thumbprint for RSA keys. + /// + /// Required members in lexicographic order: e, kty, n. + /// Result: base64url( SHA-256( UTF8( canonical JSON ) ) ), no padding. + static String _computeJkt(Map jwk) { + final canonical = jsonEncode({ + 'e': jwk['e'], + 'kty': jwk['kty'], + 'n': jwk['n'], + }); + final digest = sha256.convert(utf8.encode(canonical)); + return _base64UrlNoPad(Uint8List.fromList(digest.bytes)); + } + + static String _base64UrlNoPad(Uint8List bytes) => + base64Url.encode(bytes).replaceAll('=', ''); +} diff --git a/lib/src/new/dpop_key_manager.dart b/lib/src/new/dpop_key_manager.dart new file mode 100644 index 0000000..57cd227 --- /dev/null +++ b/lib/src/new/dpop_key_manager.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:fast_rsa/fast_rsa.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('solid_auth.DpopKeyManager'); + +/// Manages the RSA key pair used for DPoP proofs. +/// +/// ## Why this must be created BEFORE authentication +/// +/// Solid-OIDC requires DPoP key binding at the **token endpoint** level +/// (RFC 9449 §5). The client MUST send a DPoP proof JWT as the `DPoP` +/// HTTP header on the token endpoint request. The OP then: +/// +/// 1. Validates the proof. +/// 2. Computes `jkt` = base64url(SHA-256(RFC 7638 JWK thumbprint)). +/// 3. Embeds `cnf: { jkt: "…" }` in the issued access token. +/// +/// A Resource Server verifying a `PATCH` / `GET` / etc. request later +/// checks that the DPoP proof was signed by the key whose thumbprint +/// matches `cnf.jkt` in the access token. +/// +/// If the token was issued WITHOUT a DPoP proof at token-request time +/// there is no `cnf` claim at all, and the RS returns: +/// +/// > "Expected object property cnf, got: [object Object]" +/// +/// **Fix**: generate the key pair here ONCE, inject it into every token +/// request via a `package:oidc` token hook, and reuse the same key pair +/// for all subsequent resource-level DPoP proofs. +class DpopKeyManager { + DpopKeyManager._({ + required this.keyPair, + required this.publicKeyJwk, + required this.jkt, + }); + + /// RSA-2048 key pair (PEM-encoded). + final KeyPair keyPair; + + /// Public key as a JWK map — embedded in every DPoP proof JWT header. + final Map publicKeyJwk; + + /// JWK thumbprint (RFC 7638, SHA-256, base64url, no padding). + /// + /// This is what the OP stores as `cnf.jkt` inside the access token. + /// Resource Servers use this value to confirm the proof was signed by + /// the same key that was presented at token-issuance time. + final String jkt; + + // ── Singleton ───────────────────────────────────────────────────────────── + + static DpopKeyManager? _instance; + + /// Returns the cached key manager, generating a fresh pair if none exists. + /// + /// Call **before** starting the auth flow so the key is ready when the + /// token-endpoint hook fires. + static Future getInstance() async { + return _instance ??= await _generate(); + } + + /// Generates a new RSA-2048 pair and replaces the cached instance. + /// + /// Use on logout or to rotate the DPoP binding key. + static Future rotate() async { + _instance = null; + return getInstance(); + } + + /// Clears the cached instance (call on logout). + static void clear() { + _instance = null; + } + + // ── Internal ────────────────────────────────────────────────────────────── + + static Future _generate() async { + _log.fine('Generating RSA-2048 DPoP key pair'); + final keyPair = await RSA.generate(2048); + final jwk = await _buildJwk(keyPair.publicKey); + final jkt = _computeJkt(jwk); + _log.fine('DPoP key pair ready — jkt: $jkt'); + return DpopKeyManager._(keyPair: keyPair, publicKeyJwk: jwk, jkt: jkt); + } + + /// Builds an RSA JWK from PEM. Only kty/n/e are strictly required for the + /// thumbprint; alg/use are added for RS compatibility. + static Future> _buildJwk(String publicKeyPem) async { + final jwkJson = await RSA.convertPublicKeyToJWK(publicKeyPem); + final raw = jsonDecode(jwkJson) as Map; + return { + 'kty': 'RSA', + 'use': 'sig', + 'alg': 'RS256', + if (raw['n'] != null) 'n': raw['n'], + if (raw['e'] != null) 'e': raw['e'], + }; + } + + /// RFC 7638 §3.2 — JWK thumbprint for RSA keys. + /// + /// Required members in lexicographic order: e, kty, n. + /// Result: base64url( SHA-256( UTF8( canonical JSON ) ) ), no padding. + static String _computeJkt(Map jwk) { + final canonical = jsonEncode({ + 'e': jwk['e'], + 'kty': jwk['kty'], + 'n': jwk['n'], + }); + final digest = sha256.convert(utf8.encode(canonical)); + return _base64UrlNoPad(Uint8List.fromList(digest.bytes)); + } + + static String _base64UrlNoPad(Uint8List bytes) => + base64Url.encode(bytes).replaceAll('=', ''); +} diff --git a/lib/src/new/dpop_token_generator.dart b/lib/src/new/dpop_token_generator.dart new file mode 100644 index 0000000..2c40a9a --- /dev/null +++ b/lib/src/new/dpop_token_generator.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:fast_rsa/fast_rsa.dart'; +import 'package:uuid/uuid.dart'; +import 'package:logging/logging.dart'; + +import 'dpop_key_manager.dart'; + +final _log = Logger('solid_auth.DpopTokenGenerator'); +const _uuid = Uuid(); + +/// Generates DPoP (Demonstrating Proof-of-Possession) proof tokens per +/// RFC 9449 and the Solid-OIDC specification. +/// +/// ## Two kinds of DPoP proof +/// +/// ### 1. Token-endpoint proof (call [generateForTokenEndpoint]) +/// +/// Sent as the `DPoP` header on the token request to the OP. +/// The OP uses it to key-bind the issued access token by embedding +/// `cnf: { jkt: "" }` in the token payload. +/// +/// ``` +/// POST /token +/// DPoP: ← no `ath` claim here +/// Content-Type: application/x-www-form-urlencoded +/// ... +/// ``` +/// +/// ### 2. Resource-server proof (call [generateForRequest]) +/// +/// Sent alongside every protected-resource HTTP request. +/// The RS checks: +/// - `htm` matches the HTTP method. +/// - `htu` matches the request URL. +/// - `jti` has not been seen before (replay prevention). +/// - The proof is signed by the key whose thumbprint matches `cnf.jkt` +/// in the access token. +/// - `ath` = base64url(SHA-256(ASCII(access_token))). +/// +/// ``` +/// PATCH /resource +/// Authorization: DPoP +/// DPoP: ← includes `ath` claim +/// ``` +/// +/// The `cnf` error your Solid server returned means the access token was +/// issued WITHOUT step 1 — there was no DPoP proof on the token request. +abstract class DpopTokenGenerator { + DpopTokenGenerator._(); + + // ── Token-endpoint proof ─────────────────────────────────────────────────── + + /// Generates a DPoP proof for the **token endpoint request**. + /// + /// Must be sent as the `DPoP` header when calling the OP's token endpoint. + /// Do NOT include an `ath` claim here (there is no access token yet). + /// + /// [tokenEndpointUrl] — the OP token endpoint URI (e.g. + /// `https://solidcommunity.net/token`). + static Future generateForTokenEndpoint({ + required String tokenEndpointUrl, + DpopKeyManager? keyManager, + }) async { + final km = keyManager ?? await DpopKeyManager.getInstance(); + _log.fine('Generating DPoP token-endpoint proof for: $tokenEndpointUrl'); + return _build( + httpMethod: 'POST', + endpointUrl: tokenEndpointUrl, + keyPair: km.keyPair, + publicKeyJwk: km.publicKeyJwk, + accessToken: null, // no ath on token request + ); + } + + // ── Resource-server proof ────────────────────────────────────────────────── + + /// Generates a DPoP proof for a **protected resource request**. + /// + /// Automatically fetches the key pair from [DpopKeyManager.getInstance]. + /// The [accessToken] is required — it is hashed into the `ath` claim which + /// binds the proof to the specific token being used. + static Future generateForRequest({ + required String endpointUrl, + required String httpMethod, + required String accessToken, + DpopKeyManager? keyManager, + }) async { + final km = keyManager ?? await DpopKeyManager.getInstance(); + _log.fine('Generating DPoP resource proof: $httpMethod $endpointUrl'); + return _build( + httpMethod: httpMethod, + endpointUrl: endpointUrl, + keyPair: km.keyPair, + publicKeyJwk: km.publicKeyJwk, + accessToken: accessToken, + ); + } + + // ── Legacy-compatible static method ──────────────────────────────────────── + + /// Drop-in replacement for the old `genDpopToken(url, keyPair, jwk, method)`. + /// + /// Provide [accessToken] for resource requests (adds `ath` claim). + /// Omit it when generating a token-endpoint proof. + static String generate({ + required String endpointUrl, + required KeyPair keyPair, + required Map publicKeyJwk, + required String httpMethod, + String? accessToken, + }) { + return _build( + httpMethod: httpMethod, + endpointUrl: endpointUrl, + keyPair: keyPair, + publicKeyJwk: publicKeyJwk, + accessToken: accessToken, + ); + } + + // ── Core builder ─────────────────────────────────────────────────────────── + + static String _build({ + required String httpMethod, + required String endpointUrl, + required KeyPair keyPair, + required Map publicKeyJwk, + String? accessToken, + }) { + final payload = { + 'jti': _uuid.v4(), + 'htm': httpMethod.toUpperCase(), + 'htu': endpointUrl, + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }; + + // ath = base64url(SHA-256(ASCII(access_token))) per RFC 9449 §4.2 + // Required on resource requests; MUST be absent on token-endpoint proofs. + if (accessToken != null && accessToken.isNotEmpty) { + payload['ath'] = _sha256Base64Url(accessToken); + } + + final jwt = JWT( + payload, + header: JWTHeader( + algorithm: JWTAlgorithm.RS256, + typ: 'dpop+jwt', + // The public key in JWK form is embedded directly in the JWT header. + // This allows the RS to verify the signature without a key lookup. + extra: {'jwk': publicKeyJwk}, + ), + ); + + return jwt.sign(RSAPrivateKey(keyPair.privateKey)); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + /// base64url( SHA-256( ASCII( token ) ) ), no padding. + static String _sha256Base64Url(String token) { + final bytes = utf8.encode(token); // ASCII subset is valid UTF-8 + final digest = sha256.convert(bytes); + return base64Url + .encode(Uint8List.fromList(digest.bytes)) + .replaceAll('=', ''); + } +} diff --git a/lib/src/new/solid_dpop_http_client.dart b/lib/src/new/solid_dpop_http_client.dart new file mode 100644 index 0000000..68b7178 --- /dev/null +++ b/lib/src/new/solid_dpop_http_client.dart @@ -0,0 +1,105 @@ +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +import '../dpop/dpop_key_manager.dart'; +import '../dpop/dpop_token_generator.dart'; + +final _log = Logger('solid_auth.SolidDpopHttpClient'); + +/// An [http.BaseClient] that automatically injects a DPoP proof header on +/// requests to the token endpoint. +/// +/// ## Why a custom HTTP client? +/// +/// `package:oidc` accepts a custom `http.Client` via +/// `OidcUserManager(httpClient: ...)`. Every HTTP call the manager makes — +/// including the token endpoint POST — goes through this client. +/// +/// We detect token-endpoint calls by checking whether the request URL's path +/// ends with the [tokenEndpointPath] segment (or matches [tokenEndpointUri] +/// exactly if provided) and inject a fresh `DPoP` header on those requests. +/// +/// For all other requests the call is forwarded unchanged. +/// +/// ## Result +/// +/// With this client wired in, the token endpoint receives a valid DPoP proof +/// on every token request. The OP responds with an access token that includes +/// `cnf: { jkt: "…" }`, allowing the Resource Server to verify subsequent +/// DPoP-bound resource requests. +class SolidDpopHttpClient extends http.BaseClient { + SolidDpopHttpClient({ + required this.keyManager, + http.Client? inner, + this.tokenEndpointUri, + }) : _inner = inner ?? http.Client(); + + /// The DPoP key manager supplying the key pair and JWK. + final DpopKeyManager keyManager; + + /// Optional: exact URI of the token endpoint. + /// When set, only requests to this exact URI get DPoP headers. + /// When null, any request whose path contains `/token` is treated as a + /// token-endpoint call (works for all known Solid providers). + final Uri? tokenEndpointUri; + + final http.Client _inner; + + @override + Future send(http.BaseRequest request) async { + if (_isTokenEndpoint(request.url)) { + return _sendWithDpop(request); + } + return _inner.send(request); + } + + Future _sendWithDpop( + http.BaseRequest request, + ) async { + final dpopProof = await DpopTokenGenerator.generateForTokenEndpoint( + tokenEndpointUrl: _normalizedUrl(request.url), + keyManager: keyManager, + ); + + _log.fine('Injecting DPoP header on token request: ${request.url}'); + + // Clone the request and add the DPoP header. + // We must copy it because BaseRequest can only be sent once. + final copy = _copyRequest(request); + copy.headers['DPoP'] = dpopProof; + return _inner.send(copy); + } + + bool _isTokenEndpoint(Uri url) { + if (tokenEndpointUri != null) { + return url == tokenEndpointUri; + } + // Heuristic: Solid providers universally use a path ending in /token. + return url.path.endsWith('/token') || url.path.contains('/token?'); + } + + /// Strips query parameters from the URL for the `htu` claim. + /// RFC 9449 §4.2: htu MUST NOT include query or fragment components. + static String _normalizedUrl(Uri url) => + url.replace(query: '', fragment: '').toString(); + + /// Copies a [http.BaseRequest] (Request or StreamedRequest) into a + /// fresh [http.Request] with the same method, URL, headers, and body. + static http.Request _copyRequest(http.BaseRequest original) { + final copy = http.Request(original.method, original.url) + ..headers.addAll(original.headers) + ..followRedirects = original.followRedirects + ..maxRedirects = original.maxRedirects + ..persistentConnection = original.persistentConnection; + + if (original is http.Request) { + copy.bodyBytes = original.bodyBytes; + } + return copy; + } + + @override + void close() { + _inner.close(); + } +} diff --git a/lib/src/new/solid_oidc_manager_factory.dart b/lib/src/new/solid_oidc_manager_factory.dart new file mode 100644 index 0000000..9fe9ced --- /dev/null +++ b/lib/src/new/solid_oidc_manager_factory.dart @@ -0,0 +1,129 @@ +import 'package:http/http.dart' as http; +import 'package:oidc/oidc.dart'; +import 'package:oidc_default_store/oidc_default_store.dart'; +import 'package:logging/logging.dart'; + +import '../dpop/dpop_key_manager.dart'; +import '../models/solid_provider_metadata.dart'; +import '../utils/solid_scopes.dart'; +import 'solid_dpop_http_client.dart'; + +final _log = Logger('solid_auth.SolidOidcManagerFactory'); + +/// Configuration for building an [OidcUserManager] for Solid-OIDC. +class SolidOidcConfig { + const SolidOidcConfig({ + required this.clientId, + required this.redirectUri, + this.postLogoutRedirectUri, + this.scopes = SolidScopes.defaultScopes, + this.clientSecret, + this.httpClient, + this.extraTokenParameters, + this.extraAuthParameters, + }); + + final String clientId; + final Uri redirectUri; + final Uri? postLogoutRedirectUri; + final List scopes; + final String? clientSecret; + + /// Optional base HTTP client. Wrapped internally by [SolidDpopHttpClient]. + /// You do NOT need to add DPoP logic here — that is handled automatically. + final http.Client? httpClient; + + final Map? extraTokenParameters; + final Map? extraAuthParameters; +} + +/// Factory that constructs a fully configured [OidcUserManager] for +/// Solid-OIDC, with automatic DPoP key binding at the token endpoint. +/// +/// ## What changed vs the previous version +/// +/// The previous factory created an [OidcUserManager] with a plain HTTP client, +/// so token requests were sent without a `DPoP` header. The OP therefore issued +/// plain Bearer tokens (no `cnf` claim), and the Resource Server rejected them: +/// +/// > "Expected object property cnf, got: [object Object]" +/// +/// The fix is to wrap the HTTP client with [SolidDpopHttpClient], which +/// automatically injects a fresh DPoP proof on every request to the token +/// endpoint. The same [DpopKeyManager] instance is returned alongside the +/// manager so it can be reused for resource-request proofs. +abstract class SolidOidcManagerFactory { + SolidOidcManagerFactory._(); + + /// Creates an [OidcUserManager] and [DpopKeyManager], both pre-configured + /// for Solid-OIDC with automatic token-endpoint DPoP injection. + /// + /// [metadata] is optional — pass it to skip the network discovery call. + /// + /// Returns a record `(manager, keyManager)`. The [DpopKeyManager] MUST be + /// reused when generating DPoP proofs for resource requests so the key pair + /// stays consistent with the `cnf.jkt` embedded in the access token. + static Future<({OidcUserManager manager, DpopKeyManager keyManager})> create({ + required String issuerUri, + required SolidOidcConfig config, + SolidProviderMetadata? metadata, + }) async { + _log.fine('Creating OidcUserManager for issuer: $issuerUri'); + + final scopes = _ensureWebIdScope(config.scopes); + + // 1. Obtain (or generate) the DPoP key pair BEFORE the manager is used. + // The key pair must exist before the first token-endpoint call so the + // SolidDpopHttpClient can sign the proof. + final keyManager = await DpopKeyManager.getInstance(); + + // 2. Wrap the HTTP client so token requests get an injected DPoP header. + final dpopClient = SolidDpopHttpClient( + keyManager: keyManager, + inner: config.httpClient, + ); + + final clientAuth = config.clientSecret != null + ? OidcClientAuthentication.clientSecretPost( + clientId: config.clientId, + clientSecret: config.clientSecret!, + ) + : OidcClientAuthentication.none(clientId: config.clientId); + + final settings = OidcUserManagerSettings( + redirectUri: config.redirectUri, + postLogoutRedirectUri: config.postLogoutRedirectUri, + scope: scopes, + extraAuthenticationParameters: config.extraAuthParameters ?? {}, + extraTokenParameters: config.extraTokenParameters ?? {}, + ); + + final manager = metadata != null + ? OidcUserManager( + discoveryDocument: metadata.oidcMetadata, + clientCredentials: clientAuth, + store: OidcDefaultStore(), + settings: settings, + httpClient: dpopClient, // ← DPoP-aware client + ) + : OidcUserManager.lazy( + discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri( + Uri.parse(issuerUri), + ), + clientCredentials: clientAuth, + store: OidcDefaultStore(), + settings: settings, + httpClient: dpopClient, // ← DPoP-aware client + ); + + return (manager: manager, keyManager: keyManager); + } + + static List _ensureWebIdScope(List scopes) { + if (scopes.contains(SolidScopes.webid)) return scopes; + _log.warning( + 'webid scope missing — adding automatically (Solid-OIDC requirement)', + ); + return [...scopes, SolidScopes.webid]; + } +} From 7bb26a084ff59a7ad4df5522dd93710b3a081b20 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Mon, 18 May 2026 08:59:16 +1000 Subject: [PATCH 04/14] update pubspec to add crypto package --- pubspec.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pubspec.yaml b/pubspec.yaml index d0e4fa8..8f4ce12 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,9 @@ dependencies: flutter: sdk: flutter + # Crypto package + crypto: ^3.0.7 + # Core OIDC package (OpenID certified) replacing the forked openid_client oidc: ^0.14.0 oidc_core: ^0.16.0 From 594510d2ad467dfa3bbb5224d01383756251c23b Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Mon, 18 May 2026 09:00:15 +1000 Subject: [PATCH 05/14] add/update example app --- example/lib/components/Header.dart | 114 +++++++ example/lib/main.dart | 214 +++---------- example/lib/models/Constants.dart | 53 ++++ example/lib/models/GetRdfData.dart | 171 +++++++++++ example/lib/models/Responsive.dart | 81 +++++ example/lib/models/SolidApi.dart | 155 ++++++++++ example/lib/screens/EditProfile.dart | 388 ++++++++++++++++++++++++ example/lib/screens/LoginScreen.dart | 301 ++++++++++++++++++ example/lib/screens/PrivateProfile.dart | 217 +++++++++++++ example/lib/screens/PrivateScreen.dart | 80 +++++ example/lib/screens/ProfileInfo.dart | 207 +++++++++++++ example/lib/screens/PublicProfile.dart | 201 ++++++++++++ example/lib/screens/PublicScreen.dart | 60 ++++ 13 files changed, 2071 insertions(+), 171 deletions(-) create mode 100644 example/lib/components/Header.dart create mode 100644 example/lib/models/Constants.dart create mode 100644 example/lib/models/GetRdfData.dart create mode 100644 example/lib/models/Responsive.dart create mode 100644 example/lib/models/SolidApi.dart create mode 100644 example/lib/screens/EditProfile.dart create mode 100644 example/lib/screens/LoginScreen.dart create mode 100644 example/lib/screens/PrivateProfile.dart create mode 100644 example/lib/screens/PrivateScreen.dart create mode 100644 example/lib/screens/ProfileInfo.dart create mode 100644 example/lib/screens/PublicProfile.dart create mode 100644 example/lib/screens/PublicScreen.dart diff --git a/example/lib/components/Header.dart b/example/lib/components/Header.dart new file mode 100644 index 0000000..e079ad4 --- /dev/null +++ b/example/lib/components/Header.dart @@ -0,0 +1,114 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: +import 'package:flutter/material.dart'; + +// Package imports: +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; + SolidAuthManager authManager; + Header({ + Key? key, + required this.mainDrawer, + required this.authManager, + }) : 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) & (authManager.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 (authManager.isAuthenticated) + SizedBox(width: kDefaultPadding / 4), + (authManager.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: () { + authManager.logout(); + 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), + ], + ), + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 2b5a6cc..12340ed 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,183 +1,55 @@ -// example/lib/main.dart +/// SolidPod library to support privacy first data store on Solid Servers +/// +/// Copyright (C) 2026, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. // -// Demonstrates the restructured solid_auth API using package:oidc. -// Mirrors the usage example from the old solid_auth README. - +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: import 'package:flutter/material.dart'; -import 'package:solid_auth/solid_auth.dart'; -import 'package:logging/logging.dart'; -void main() { - // Optional: configure logging for debugging. - Logger.root.level = Level.FINE; - Logger.root.onRecord.listen( - (r) => debugPrint('[${r.loggerName}] ${r.level.name}: ${r.message}')); +// Project imports: +import 'package:solid_auth_example/screens/LoginScreen.dart'; - runApp(const SolidAuthExampleApp()); +void main() { + runApp(MyApp()); } -class SolidAuthExampleApp extends StatelessWidget { - const SolidAuthExampleApp({super.key}); - +class MyApp extends StatelessWidget { + // This widget is the root of the application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Solid Auth Example', - home: const LoginPage(), + debugShowCheckedModeBanner: false, + title: 'Flutter Solid Authentication', + theme: ThemeData(), + home: LoginScreen(), ); } } - -class LoginPage extends StatefulWidget { - const LoginPage({super.key}); - - @override - State createState() => _LoginPageState(); -} - -class _LoginPageState extends State { - // ── 1. Create the manager once ───────────────────────────────────────────── - // - // SolidAuthManager wraps OidcUserManager. You typically hold this at the - // app or provider level (Riverpod, BLoC, etc.). - final _auth = SolidAuthManager( - config: SolidOidcConfig( - // clientId: 'my_solid_client', - - // // On mobile: a custom-scheme URI registered with the OS. - // // On web: the path to your redirect.html (see package:oidc docs). - // redirectUri: Uri.parse('com.example.solidapp://callback'), - - // postLogoutRedirectUri: Uri.parse('com.example.solidapp://callback'), - - clientId: - 'https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld', - - // On mobile: a custom-scheme URI registered with the OS. - // On web: the path to your redirect.html (see package:oidc docs). - redirectUri: Uri.parse('com.example.solid.auth.example://redirect'), - - postLogoutRedirectUri: Uri.parse( - 'com.example.solid.auth.example://logout'), //Uri.parse('${appUrlScheme}://logout'), - - // Solid-OIDC scopes — webid is always added automatically. - scopes: SolidScopes.defaultScopes, - ), - ); - - SolidAuthData? _authData; - String? _error; - bool _loading = false; - - // ── 2. Authenticate from a WebID ─────────────────────────────────────────── - - Future _login() async { - const webId = 'https://pods.solidcommunity.au/'; - - setState(() { - _loading = true; - _error = null; - }); - - try { - // getIssuer() + OidcUserManager.init() + loginAuthorizationCodeFlow() - // are all handled internally. - final authData = await _auth.loginFromWebId(webId); - print('here'); - print(authData); - - setState(() => _authData = authData); - } on SolidAuthException catch (e) { - setState(() => _error = e.message); - } finally { - setState(() => _loading = false); - } - } - - // ── 3. Generate a DPoP token for a resource request ─────────────────────── - - Future _fetchPrivateResource() async { - if (_authData == null) return; - - const resourceUrl = 'https://charlieb.solidcommunity.net/private/data.ttl'; - - final dpopToken = await DpopTokenGenerator.generateForRequest( - endpointUrl: resourceUrl, - httpMethod: 'GET', - accessToken: _authData!.accessToken, - ); - - // Use the token in the HTTP request: - // headers: { - // 'Authorization': 'DPoP ${_authData!.accessToken}', - // 'DPoP': dpopToken, - // } - debugPrint('DPoP token: $dpopToken'); - } - - // ── 4. Fetch public profile ──────────────────────────────────────────────── - - Future _fetchProfile() async { - final profile = await const ProfileFetcher().fetchProfile(_authData!.webId); - debugPrint('Name: ${profile.name}'); - debugPrint('Storage: ${profile.storage}'); - debugPrint('Issuer: ${profile.oidcIssuer}'); - } - - // ── 5. Logout ───────────────────────────────────────────────────────────── - - Future _logout() async { - await _auth.logout(); - setState(() => _authData = null); - } - - @override - void dispose() { - _auth.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Solid Auth Example')), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_error != null) - Text('Error: $_error', style: const TextStyle(color: Colors.red)), - if (_authData != null) ...[ - Text('WebID: ${_authData!.webId}'), - Text('Issuer: ${_authData!.issuer}'), - Text('Expired: ${_authData!.isExpired}'), - const SizedBox(height: 8), - ElevatedButton( - onPressed: _fetchPrivateResource, - child: const Text('Generate DPoP Token')), - ElevatedButton( - onPressed: _fetchProfile, child: const Text('Fetch Profile')), - ElevatedButton(onPressed: _logout, child: const Text('Logout')), - ] else - ElevatedButton( - onPressed: _loading ? null : _login, - child: _loading - ? const CircularProgressIndicator() - : const Text('Login with Solid'), - ), - ], - ), - ), - ); - } -} - -// ── Issuer-only flow (matching old authenticate() API) ───────────────────── -// -// If you already have the issuer URI (e.g. from your own discovery logic), -// you can bypass WebID resolution: -// -// final authData = await _auth.login( -// issuerUri: 'https://solidcommunity.net', -// ); diff --git a/example/lib/models/Constants.dart b/example/lib/models/Constants.dart new file mode 100644 index 0000000..a14aebb --- /dev/null +++ b/example/lib/models/Constants.dart @@ -0,0 +1,53 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: +import 'package:flutter/material.dart'; + +// All the contants +const darkGold = Color(0xFFBE830E); +const brickRed = Color(0xFFD89E7A); +const lightGold = Color(0xFFDBBA78); +const exLightBlue = Color(0xFFD8ECF3); +const darkCopper = Color(0xFFBE4E0E); +const titleAsh = Color(0xFF30384D); +const backgroundWhite = Color(0xFFF5F6FC); +const lightGray = Color(0xFF8793B2); +const bgOffWhite = Color(0xFFF2F4FC); + +const kDefaultPadding = 20.0; + +double screenWidth(BuildContext context) => MediaQuery.of(context).size.width; +// double screenHeight(BuildContext context) => MediaQuery.of(context).size.height; diff --git a/example/lib/models/GetRdfData.dart b/example/lib/models/GetRdfData.dart new file mode 100644 index 0000000..b44312a --- /dev/null +++ b/example/lib/models/GetRdfData.dart @@ -0,0 +1,171 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Class to read the turtle files and extract values from triples +class PodProfile { + String profileRdfStr = ''; + + PodProfile(String profileRdfStr) { + this.profileRdfStr = profileRdfStr; + } + + List divideRdfData(String profileRdfStr) { + List rdfDataList = []; + String vcardPrefix = ''; + String foafPrefix = ''; + + var profileDataList = profileRdfStr.split('\n'); + for (var i = 0; i < profileDataList.length; i++) { + String dataItem = profileDataList[i]; + if (dataItem.contains(';')) { + var itemList = dataItem.split(';'); + for (var j = 0; j < itemList.length; j++) { + String item = itemList[j]; + rdfDataList.add(item); + } + } else { + rdfDataList.add(dataItem); + } + + if (dataItem.contains('')) { + var itemList = dataItem.split(' '); + vcardPrefix = itemList[1]; + } + + if (dataItem.contains('')) { + var itemList = dataItem.split(' '); + foafPrefix = itemList[1]; + } + } + return [rdfDataList, vcardPrefix, foafPrefix]; + } + + List dividePrvRdfData() { + List rdfDataList = []; + final Map prefixList = {}; + + var profileDataList = profileRdfStr.split('\n'); + for (var i = 0; i < profileDataList.length; i++) { + String dataItem = profileDataList[i]; + if (dataItem.contains(';')) { + var itemList = dataItem.split(';'); + for (var j = 0; j < itemList.length; j++) { + String item = itemList[j]; + rdfDataList.add(item); + } + } else { + rdfDataList.add(dataItem); + } + + if (dataItem.contains('@prefix')) { + var itemList = dataItem.split(' '); + prefixList[itemList[1]] = itemList[2]; + } + } + return [rdfDataList, prefixList]; + } + + String getProfPicture() { + var rdfRes = divideRdfData(profileRdfStr); + List rdfDataList = rdfRes[0]; + String vcardPrefix = rdfRes[1]; + String foafPrefix = rdfRes[2]; + String pictureUrl = ''; + String optionalPictureUrl = ''; + for (var i = 0; i < rdfDataList.length; i++) { + String dataItem = rdfDataList[i]; + if (dataItem.contains(vcardPrefix + 'hasPhoto')) { + var itemList = dataItem.split('<'); + pictureUrl = itemList[1].replaceAll('>', ''); + } + if (dataItem.contains(foafPrefix + 'img')) { + var itemList = dataItem.split('<'); + optionalPictureUrl = itemList[1].replaceAll('>', ''); + } + } + if (pictureUrl.isEmpty & optionalPictureUrl.isNotEmpty) { + pictureUrl = optionalPictureUrl; + } + return pictureUrl; + } + + 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')) { + var itemList = dataItem.split('"'); + profName = itemList[1]; + } + } + if (profName.isEmpty) { + profName = 'John Doe'; + } + return profName; + } + + 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)) { + var itemList = dataItem.split('"'); + personalInfo = itemList[1]; + } + } + return personalInfo; + } + + 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)) { + var itemList = dataItem.split(':'); + personalInfo = itemList[2]; + } + } + return personalInfo; + } +} diff --git a/example/lib/models/Responsive.dart b/example/lib/models/Responsive.dart new file mode 100644 index 0000000..0594013 --- /dev/null +++ b/example/lib/models/Responsive.dart @@ -0,0 +1,81 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: +import 'package:flutter/material.dart'; + +// Project imports: +import 'package:solid_auth_example/models/Constants.dart'; + +// Widget to setup respostive designs +class Responsive extends StatelessWidget { + final Widget mobile; + final Widget tablet; + final Widget desktop; + + const Responsive({ + Key? key, + required this.mobile, + required this.tablet, + required this.desktop, + }) : super(key: key); + + static bool isMobile(BuildContext context) => screenWidth(context) < 650; + + static bool isTablet(BuildContext context) => + screenWidth(context) < 1100 && screenWidth(context) >= 650; + + static bool isDesktop(BuildContext context) => screenWidth(context) >= 1100; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + //If width is more than 1100 consider it as desktop + if (constraints.maxWidth >= 1100) { + return desktop; + } + //If width is in between 1100 and 650 consider it as tablet + else if (constraints.maxWidth >= 650) { + return tablet; + } + //If width is less than 650 consider it as mobile + else { + return mobile; + } + }, + ); + } +} diff --git a/example/lib/models/SolidApi.dart b/example/lib/models/SolidApi.dart new file mode 100644 index 0000000..ecb8281 --- /dev/null +++ b/example/lib/models/SolidApi.dart @@ -0,0 +1,155 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Dart imports: +import 'dart:async'; + +// Package imports: +import 'package:http/http.dart' as http; +import 'package:solid_auth/solid_auth.dart'; + +// Get private profile information using access and dPoP tokens +Future fetchPrvProfile( + String profCardUrl, SolidAuthData authData) async { + final dPopToken = await DpopTokenGenerator.generateForRequest( + endpointUrl: profCardUrl, + httpMethod: 'GET', + accessToken: authData.accessToken, + ); + + final profResponse = await http.get( + Uri.parse(profCardUrl), + headers: { + 'Accept': '*/*', + 'Authorization': 'DPoP ${authData.accessToken}', + 'Connection': 'keep-alive', + 'DPoP': '$dPopToken', + }, + ); + + if (profResponse.statusCode == 200) { + // If the server did return a 200 OK response, + // then parse the JSON. + return profResponse.body; + } else { + // If the server did not return a 200 OK response, + // then throw an exception. + throw Exception('Failed to load profile data! Try again in a while.'); + } +} + +// Update profile information +Future updateProfile(String profCardUrl, String accessToken, + String dPopToken, String query) async { + 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, + }, + body: query, + ); + + if (editResponse.statusCode == 200 || editResponse.statusCode == 205) { + // If the server did return a 200 OK response, + // then parse the JSON. + return 'success'; + } else { + // If the server did not return a 200 OK response, + // then throw an exception. + throw Exception('Failed to write profile data! Try again in a while.'); + } +} + +// Generate Sparql query +String genSparqlQuery( + String action, String subject, String predicate, String object, + {String? prevObject, String? format}) { + String query = ''; + + switch (action) { + case "INSERT": + { + query = 'INSERT DATA {<$subject> <$predicate> "$object".};'; + } + break; + + case "DELETE": + { + query = 'DELETE DATA {<$subject> <$predicate> "$object".};'; + } + break; + + case "UPDATE": + { + query = + 'DELETE DATA {<$subject> <$predicate> "$prevObject".}; INSERT DATA {<$subject> <$predicate> "$object".};'; + } + break; + + case "UPDATE_LANG": + { + query = + 'DELETE DATA {<$subject> <$predicate> "$prevObject"@en.}; INSERT DATA {<$subject> <$predicate> "$object"@en.};'; + } + break; + + case "UPDATE_DATE": + { + query = + 'DELETE DATA {<$subject> <$predicate> "$prevObject"^^<$format>.}; ' + + 'INSERT DATA {<$subject> <$predicate> "$object"^^<$format>.};'; + } + break; + + case "READ": + { + query = "Invalid"; + } + break; + + default: + { + query = "Invalid"; + } + break; + } + + return query; +} diff --git a/example/lib/screens/EditProfile.dart b/example/lib/screens/EditProfile.dart new file mode 100644 index 0000000..020a529 --- /dev/null +++ b/example/lib/screens/EditProfile.dart @@ -0,0 +1,388 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: +import 'package:flutter/material.dart'; + +// Package imports: +//import 'package:jwt_decoder/jwt_decoder.dart'; +import 'package:solid_auth/solid_auth.dart'; + +import 'package:solid_auth_example/components/Header.dart'; +// Project imports: +import 'package:solid_auth_example/models/Constants.dart'; +import 'package:solid_auth_example/models/SolidApi.dart'; +import 'package:solid_auth_example/screens/PrivateScreen.dart'; + +class EditProfile extends StatefulWidget { + final SolidAuthData authData; + final SolidAuthManager authManager; + final String webId; + final Map profData; + const EditProfile({ + Key? key, + required this.authData, + required this.authManager, + required this.webId, + required this.profData, + }) : super(key: key); + + @override + _EditProfileState createState() => _EditProfileState(); +} + +class _EditProfileState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + // Text editing controllers + late TextEditingController nameController; + late TextEditingController dobController; + late TextEditingController occController; + late TextEditingController orgController; + + @override + void initState() { + super.initState(); + nameController = TextEditingController(text: widget.profData['name']); + dobController = TextEditingController(text: widget.profData['dob']); + occController = TextEditingController(text: widget.profData['occ']); + orgController = TextEditingController(text: widget.profData['org']); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + body: SafeArea( + child: Column( + children: [ + Header(mainDrawer: _scaffoldKey, authManager: widget.authManager), + Divider(thickness: 1), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.all(kDefaultPadding * 1.5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + Stack( + children: [ + Row( + children: [ + Icon(Icons.menu_book_rounded, + color: brickRed), + SizedBox(width: 10.0), + Text("Edit Profile Info", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700)), + ], + ), + ], + ), + SizedBox( + height: 50, + ), + createInputField( + "NAME", nameController, widget.profData['name']), + createInputDateField("DATE OF BIRTH", dobController, + widget.profData['dob']), + createInputField("OCCUPATION", occController, + widget.profData['occ']), + createInputField("ORGANISATION", orgController, + widget.profData['org']), + SizedBox( + height: 20, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => PrivateScreen( + authData: widget.authData, + authManager: widget.authManager, + )), + ); + }, + style: OutlinedButton.styleFrom( + padding: + EdgeInsets.symmetric(horizontal: 40), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(20))), + child: Text( + "CANCEL", + style: TextStyle( + color: darkGold, + letterSpacing: 2.0, + fontSize: 15.0, + fontWeight: FontWeight.bold, + fontFamily: 'Poppins', + ), + )), + SizedBox( + width: 10, + ), + ElevatedButton( + onPressed: () async { + final authData = widget.authData; + + // Get access token + String accessToken = authData.accessToken; + + // Get profile URI + String profCardUrl = + widget.webId.replaceAll('#me', ''); + + String dPopToken = await DpopTokenGenerator + .generateForRequest( + endpointUrl: profCardUrl, + httpMethod: 'PATCH', + accessToken: accessToken, + ); + + List attrList = [ + 'name', + 'dob', + 'occ', + 'org' + ]; // Attribute list + Map predicateMap = { + 'name': 'fn', + 'dob': 'bday', + 'occ': 'role', + 'org': 'organization-name' + }; // predicate name list + int numOfUpdates = 0; + + // Loop through attribute list and check for changes + // if there are any update those + for (var i = 0; i < attrList.length; i++) { + String attr = attrList[i]; + String prevVal = ''; + String newVal = ''; + + switch (attr) { + case 'name': + { + prevVal = widget.profData['name']; + newVal = nameController.text; + } + break; + case 'dob': + { + prevVal = widget.profData['dob']; + newVal = dobController.text; + } + break; + case 'occ': + { + prevVal = widget.profData['occ']; + newVal = occController.text; + } + break; + case 'org': + { + prevVal = widget.profData['org']; + newVal = orgController.text; + } + break; + default: + { + print('Invalid attribute name'); + } + } + + // If the value in an attribute is changed + if ((prevVal != '' && newVal != '') && + (prevVal != newVal)) { + String updateQuery = ''; + + // Generate update query + if (attr == 'dob') { + updateQuery = genSparqlQuery( + 'UPDATE_DATE', + widget.webId, + 'http://www.w3.org/2006/vcard/ns#' + + predicateMap[attr], + newVal, + prevObject: prevVal, + format: + 'http://www.w3.org/2001/XMLSchema#date'); + } else { + updateQuery = genSparqlQuery( + 'UPDATE', + widget.webId, + 'http://www.w3.org/2006/vcard/ns#' + + predicateMap[attr], + newVal, + prevObject: prevVal); + } + + // Update profile using the generated query + String updateResponse = + await updateProfile( + profCardUrl, + accessToken, + dPopToken, + updateQuery); + numOfUpdates += 1; + assert(updateResponse == 'success'); + } + } + + print( + '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, + authManager: widget.authManager, + )), + ); + }, + style: ElevatedButton.styleFrom( + foregroundColor: darkGold, + backgroundColor: lightGold, // foreground + padding: + EdgeInsets.symmetric(horizontal: 50), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(20))), + child: Text( + "UPDATE", + style: TextStyle( + color: Colors.white, + letterSpacing: 2.0, + fontSize: 15.0, + fontWeight: FontWeight.bold, + ), + )), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + // Create input field for texual values + TextField createInputField( + String labelText, TextEditingController controller, String initValue, + {double rowHeight = 25.0}) { + return TextField( + controller: controller, + decoration: InputDecoration( + //contentPadding: EdgeInsets.only(top: 5), + //contentPadding: EdgeInsets.all(0.0), + isDense: true, + contentPadding: EdgeInsets.fromLTRB(0.0, rowHeight, 0.0, 5.0), + labelText: "$labelText", + labelStyle: TextStyle( + color: titleAsh, + letterSpacing: 2.0, + fontSize: 16.0, + fontWeight: FontWeight.bold, + ), + floatingLabelBehavior: FloatingLabelBehavior.always, + hintStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black, + )), + ); + } + + // Create input field for date values + TextField createInputDateField( + String labelText, TextEditingController controller, String initValue) { + return TextField( + controller: controller, + readOnly: true, + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.fromLTRB(0.0, 25.0, 0.0, 5.0), + labelText: "$labelText", + floatingLabelBehavior: FloatingLabelBehavior.always, + hintStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black, + )), + onTap: () async { + var date = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime(2100), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: lightGold, // header background color + onPrimary: Colors.white, // header text color + onSurface: darkCopper, // body text color + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: Colors.red, // button text color + ), + ), + ), + child: child!, + ); + }, + ); + controller.text = date.toString().substring(0, 10); + }, + ); + } +} diff --git a/example/lib/screens/LoginScreen.dart b/example/lib/screens/LoginScreen.dart new file mode 100644 index 0000000..7d9d933 --- /dev/null +++ b/example/lib/screens/LoginScreen.dart @@ -0,0 +1,301 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +/// Copyright (C) 2026, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: +import 'package:flutter/material.dart'; + +//import 'package:solid_auth_example/models/RestAPI.dart'; +//import 'package:solid_auth/solid_auth.dart'; +import 'package:solid_auth/solid_auth.dart'; +// Package imports: +import 'package:url_launcher/url_launcher.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'; + +// ignore: must_be_immutable +class LoginScreen extends StatelessWidget { + // Sample web ID to check the functionality + var webIdController = TextEditingController() + ..text = 'https://pods.solidcommunity.au/'; + + @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( + image: AssetImage('assets/images/background.jpg'), + 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', + 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', + 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', + ), + ), + )), + ], + ), + ], + ), + ), + ), + ), + )), + ], + ), + ))); + } + + // POD issuer registration page launch + launchIssuerReg(String _issuerUri) async { + var url = '$_issuerUri/register'; + + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl(Uri.parse(url)); + } else { + throw 'Could not launch $url'; + } + } + + // Create login row for SOLID POD issuer + Row createSolidLoginRow( + 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), + ), + ), + onPressed: () async => launchIssuerReg( + (await WebIdUtils.getIssuer(_webIdTextController.text)) + .toString()), + child: Text( + 'GET A POD', + style: TextStyle( + color: titleAsh, + letterSpacing: 2.0, + fontSize: 15.0, + fontWeight: FontWeight.bold, + ), + ), + )), + SizedBox( + width: 15.0, + ), + Expanded( + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.all(20), + backgroundColor: lightGold, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () async { + // Define Solid Auth Manager + final authManager = SolidAuthManager( + config: SolidOidcConfig( + // clientId: 'my_solid_client', + + // // On mobile: a custom-scheme URI registered with the OS. + // // On web: the path to your redirect.html (see package:oidc docs). + // redirectUri: Uri.parse('com.example.solidapp://callback'), + + // postLogoutRedirectUri: Uri.parse('com.example.solidapp://callback'), + + clientId: + 'https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld', + + // On mobile: a custom-scheme URI registered with the OS. + // On web: the path to your redirect.html (see package:oidc docs). + redirectUri: Uri.parse('http://localhost:0/redirect'), + + postLogoutRedirectUri: Uri.parse( + 'http://localhost:0/redirect'), //Uri.parse('${appUrlScheme}://logout'), + + // Solid-OIDC scopes — webid is always added automatically. + scopes: SolidScopes.defaultScopes, + ), + ); + + // Authentication process for the POD issuer + try { + // getIssuer() + OidcUserManager.init() + loginAuthorizationCodeFlow() + // are all handled internally. + final authData = + await authManager.authenticate(webIdController.text); + + // Navigate to the profile through main screen + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => PrivateScreen( + authData: authData, + authManager: authManager, + )), + ); + } on SolidAuthException catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Login failed! \n ${e.message})'), + duration: const Duration(milliseconds: 3000), + )); + } + }, + child: Text( + 'LOGIN', + style: TextStyle( + color: Colors.white, + letterSpacing: 2.0, + fontSize: 15.0, + fontWeight: FontWeight.bold, + fontFamily: 'Poppins', + ), + ), + ), + ), + ], + ); + } +} diff --git a/example/lib/screens/PrivateProfile.dart b/example/lib/screens/PrivateProfile.dart new file mode 100644 index 0000000..92e8195 --- /dev/null +++ b/example/lib/screens/PrivateProfile.dart @@ -0,0 +1,217 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: +import 'package:flutter/material.dart'; + +import 'package:solid_auth/solid_auth.dart'; + +import 'package:solid_auth_example/components/Header.dart'; +// Project imports: +import 'package:solid_auth_example/models/Constants.dart'; +import 'package:solid_auth_example/models/GetRdfData.dart'; +import 'package:solid_auth_example/models/SolidApi.dart' as rest_api; +import 'package:solid_auth_example/screens/ProfileInfo.dart'; + +class PrivateProfile extends StatefulWidget { + final SolidAuthData authData; // Authentication data + final SolidAuthManager authManager; + const PrivateProfile( + {Key? key, required this.authData, required this.authManager}) + : super(key: key); + + @override + State createState() => _PrivateProfileState(); +} + +class _PrivateProfileState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + // Loading widget + Widget _loadingScreen() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + new Container( + alignment: AlignmentDirectional.center, + decoration: new BoxDecoration( + color: backgroundWhite, + ), + child: new Container( + decoration: new BoxDecoration( + color: lightGold, + borderRadius: new BorderRadius.circular(25.0)), + width: 300.0, + height: 200.0, + alignment: AlignmentDirectional.center, + child: new Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + new Center( + child: new SizedBox( + height: 50.0, + width: 50.0, + child: new CircularProgressIndicator( + value: null, + color: backgroundWhite, + strokeWidth: 7.0, + ), + ), + ), + new Container( + margin: const EdgeInsets.only(top: 25.0), + child: new Center( + child: new Text( + "Loading.. Please wait!", + style: new TextStyle(fontSize: 20, color: Colors.white), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _loadedScreen(Object profInfo, String webId, SolidAuthData authData) { + // Read profile info from the turtle file + PodProfile podProfile = PodProfile(profInfo.toString()); + + String profPic = + podProfile.getProfPicture(); // Get the url for profile picture + String profName = podProfile.getProfName(); // Get name + String profDob = podProfile.getPersonalInfo('bday'); // Get birthday + String profOcc = podProfile.getPersonalInfo('role'); // Get occupation + String profOrg = + podProfile.getPersonalInfo('organization-name'); // Get organisation + String profCoun = podProfile.getPersonalInfo('country-name'); // Get country + // String profReg = podProfile.getPersonalInfo('region'); // Get state + // String profAddId = + // podProfile.getAddressId('hasAddress'); // Get hasAddress flag + + // Set up correct profile picture url + String picUrl = webId; + if (profPic.contains('http')) { + picUrl = profPic; + } else { + if (profPic != '') { + picUrl = picUrl.replaceAll('card#me', profPic); + } else { + picUrl = + 'https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg'; + } + } + + // Store profile data in a dictionary + Map profData = { + 'name': profName, + 'picUrl': picUrl, + 'dob': profDob, + 'occ': profOcc, + 'org': profOrg, + 'loc': profCoun, + }; + + // Load profile info screen + return Container( + color: Colors.white, + child: Column( + children: [ + Header(mainDrawer: _scaffoldKey, authManager: widget.authManager), + Divider(thickness: 1), + Expanded( + child: SingleChildScrollView( + controller: ScrollController(), + padding: EdgeInsets.all(kDefaultPadding * 1.5), + child: ProfileInfo( + profData: profData, + authManager: widget.authManager, + profType: 'private', + webId: webId, + authData: authData)), + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + SolidAuthData authData = widget.authData; + String webId = authData.webId; + + // Get profile url + String profCardUrl = webId.replaceAll('#me', ''); + + return Scaffold( + key: _scaffoldKey, + body: SafeArea( + child: FutureBuilder( + future: rest_api.fetchPrvProfile(profCardUrl, authData), + builder: (context, snapshot) { + Widget returnVal; + if (snapshot.hasData) { + returnVal = _loadedScreen(snapshot.data!, webId, authData); + } else { + returnVal = _loadingScreen(); + } + return returnVal; + }), + + // Container( + // color: Colors.white, + // child: Column( + // children: [ + // Header(mainDrawer: _scaffoldKey), + // Divider(thickness: 1), + // Expanded( + // child: SingleChildScrollView( + // padding: EdgeInsets.all(kDefaultPadding*1.5), + // child: screenWidth(context) > 1250 ? + // ProfileDesktop(profName:'Anushka Vidanage') + // : ProfileMobile(profName:'Anushka Vidanage') + // ), + // ) + // ], + // ), + // ), + ), + ); + } +} diff --git a/example/lib/screens/PrivateScreen.dart b/example/lib/screens/PrivateScreen.dart new file mode 100644 index 0000000..becedca --- /dev/null +++ b/example/lib/screens/PrivateScreen.dart @@ -0,0 +1,80 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: +import 'package:flutter/material.dart'; +import 'package:solid_auth/solid_auth.dart'; + +import 'package:solid_auth_example/models/Constants.dart'; +// Project imports: +import 'package:solid_auth_example/models/Responsive.dart'; +import 'package:solid_auth_example/screens/PrivateProfile.dart'; + +// ignore: must_be_immutable +class PrivateScreen extends StatelessWidget { + SolidAuthData authData; // Authentication data + SolidAuthManager authManager; + PrivateScreen({Key? key, required this.authData, required this.authManager}) + : super(key: key); + + @override + Widget build(BuildContext context) { + // Assign loading screen + var loadingScreen = + PrivateProfile(authData: authData, authManager: authManager); + + // Setup Scaffold to be responsive + return Scaffold( + body: Responsive( + mobile: loadingScreen, + tablet: Row( + children: [ + Expanded( + flex: 10, + child: loadingScreen, + ), + ], + ), + desktop: Row( + children: [ + Expanded( + flex: screenWidth(context) < 1300 ? 10 : 8, + child: loadingScreen, + ), + ], + ), + )); + } +} diff --git a/example/lib/screens/ProfileInfo.dart b/example/lib/screens/ProfileInfo.dart new file mode 100644 index 0000000..b372f5e --- /dev/null +++ b/example/lib/screens/ProfileInfo.dart @@ -0,0 +1,207 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// 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 SolidAuthData? authData; // Authentication related data + final SolidAuthManager? authManager; + final String profType; // Public or private + final String? webId; // WebId of the user + + const ProfileInfo( + {Key? key, + required this.profData, + this.authManager, + required this.profType, + this.authData, + this.webId}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + Stack( + children: [ + Container( + width: 130, + height: 130, + decoration: BoxDecoration( + border: Border.all( + width: 4, + color: Theme.of(context).scaffoldBackgroundColor, + ), + boxShadow: [ + BoxShadow( + spreadRadius: 2, + blurRadius: 10, + color: Colors.black.withValues(alpha: 0.1), + offset: Offset(0, 10)) + ], + shape: BoxShape.circle, + image: DecorationImage( + fit: BoxFit.cover, + image: NetworkImage(profData['picUrl']))), + ), + if (profType == 'private') + Positioned( + bottom: 0, + right: 0, + child: Container( + height: 45, + width: 45, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + width: 3, + color: Theme.of(context).scaffoldBackgroundColor, + ), + color: darkCopper, + ), + child: IconButton( + icon: new Icon(Icons.edit), + color: Colors.white, + onPressed: () { + // Navigate to the profile edit function + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => EditProfile( + authData: authData!, + authManager: authManager!, + webId: webId!, + profData: profData, + )), + ); + }, + ), + )), + ], + ), + // Display profile data + SizedBox( + height: 50, + ), + profileMenuItem("BASIC INFORMATION"), + SizedBox( + height: 20, + ), + buildLabelRow('Name', profData['name']), + buildLabelRow('Birthday', profData['dob']), + buildLabelRow('Country', profData['loc']), + // + profileMenuItem("WORK"), + SizedBox( + height: 20, + ), + buildLabelRow('Occupation', profData['occ']), + buildLabelRow('Organisation', profData['org']), + // + ], + ), + ), + ], + ); + } + + // A menu item + Row profileMenuItem(String title) { + return Row(children: [ + Text( + title, + style: TextStyle( + color: lightGray, + letterSpacing: 2.0, + fontSize: 12.0, + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: new Container( + margin: const EdgeInsets.only(left: 10.0, right: 0.0), + child: Divider( + color: lightGray, + height: 36, + )), + ), + ]); + } + + // A profile info row + Column buildLabelRow(String labelName, String profName) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$labelName:', + style: TextStyle( + color: titleAsh, + letterSpacing: 2.0, + fontSize: 14.0, + fontWeight: FontWeight.bold, + ), + ), + Text( + profName, + style: TextStyle( + color: Colors.grey[800], + letterSpacing: 2.0, + fontSize: 14.0, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + SizedBox( + height: 30, + ) + ], + ); + } +} diff --git a/example/lib/screens/PublicProfile.dart b/example/lib/screens/PublicProfile.dart new file mode 100644 index 0000000..75670ef --- /dev/null +++ b/example/lib/screens/PublicProfile.dart @@ -0,0 +1,201 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +// Project imports: +import 'package:solid_auth_example/models/Constants.dart'; +import 'package:solid_auth_example/components/Header.dart'; +import 'package:solid_auth_example/models/GetRdfData.dart'; +import 'package:solid_auth_example/screens/ProfileInfo.dart'; + +class PublicProfile extends StatefulWidget { + final String webId; + + const PublicProfile({Key? key, required this.webId}) : super(key: key); + + @override + State createState() => _PublicProfileState(); +} + +class _PublicProfileState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + /// 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.'); + } + } + + // Loading widget + Widget _loadingScreen() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + new Container( + alignment: AlignmentDirectional.center, + decoration: new BoxDecoration( + color: backgroundWhite, + ), + child: new Container( + decoration: new BoxDecoration( + color: lightGold, + borderRadius: new BorderRadius.circular(25.0)), + width: 300.0, + height: 200.0, + alignment: AlignmentDirectional.center, + child: new Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + new Center( + child: new SizedBox( + height: 50.0, + width: 50.0, + child: new CircularProgressIndicator( + value: null, + color: backgroundWhite, + strokeWidth: 7.0, + ), + ), + ), + new Container( + margin: const EdgeInsets.only(top: 25.0), + child: new Center( + child: new Text( + "Loading.. Please wait!", + style: new TextStyle(fontSize: 20, color: Colors.white), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + // Loaded screen + Widget _loadedScreen(Object profInfo, String webId) { + // Get profile information from the .ttl file + PodProfile podProfile = PodProfile(profInfo.toString()); + String profPic = podProfile.getProfPicture(); + String profName = podProfile.getProfName(); + String profDob = podProfile.getPersonalInfo('bday'); + String profOcc = podProfile.getPersonalInfo('role'); + String profOrg = podProfile.getPersonalInfo('organization-name'); + String profCoun = podProfile.getPersonalInfo('country-name'); + + // Set profile picture url (if any) + String picUrl = webId; + if (profPic.contains('http')) { + picUrl = profPic; + } else { + if (profPic != '') { + picUrl = picUrl.replaceAll('card#me', profPic); + } else { + // Dafault picture + picUrl = + 'https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg'; + } + } + + // Store profile info + Map profData = { + 'name': profName, + 'picUrl': picUrl, + 'dob': profDob, + 'occ': profOcc, + 'org': profOrg, + 'loc': profCoun, + }; + + return Container( + color: Colors.white, + child: Column( + children: [ + //Header(mainDrawer: _scaffoldKey, authData: ), + Divider(thickness: 1), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.all(kDefaultPadding * 1.5), + child: ProfileInfo(profData: profData, profType: 'public')), + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + String webId = widget.webId; + + return Scaffold( + key: _scaffoldKey, + body: SafeArea( + child: FutureBuilder( + future: _fetchProfileData( + webId), // Get profile data (.ttl file) from the webId + builder: (context, snapshot) { + Widget returnVal; + if (snapshot.connectionState == ConnectionState.done) { + returnVal = _loadedScreen(snapshot.data!, webId); + } else { + returnVal = _loadingScreen(); + } + return returnVal; + }), + ), + ); + } +} diff --git a/example/lib/screens/PublicScreen.dart b/example/lib/screens/PublicScreen.dart new file mode 100644 index 0000000..31b248c --- /dev/null +++ b/example/lib/screens/PublicScreen.dart @@ -0,0 +1,60 @@ +/// SolidPod library to support privacy first data store on Solid Servers +/// +// Time-stamp: +/// +/// Copyright (C) 2025, Software Innovation Institute ANU +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: AUTHORS + +// Add the library directive as we have doc entries above. We publish the above +// meta doc lines in the docs. + +library; + +// Flutter imports: +import 'package:flutter/material.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; + + PublicScreen({Key? key, required this.webId}) : super(key: key); + + @override + Widget build(BuildContext context) { + // Navigate to public profile with a loading screen + var loadingScreen = PublicProfile(webId: webId); + return Scaffold( + body: Responsive( + mobile: loadingScreen, + tablet: loadingScreen, + desktop: loadingScreen, + )); + } +} From b920f20137e25f3dd70ac8c8deed694f15541af6 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Mon, 18 May 2026 09:00:33 +1000 Subject: [PATCH 06/14] remove old files --- lib/src/new/dpop_key_manager(1).dart | 119 ------------ lib/src/new/dpop_key_manager.dart | 119 ------------ lib/src/new/dpop_token_generator.dart | 170 ----------------- lib/src/new/solid_dpop_http_client.dart | 105 ----------- lib/src/new/solid_oidc_manager_factory.dart | 129 ------------- lib/src/profile/profile_fetcher.dart | 195 -------------------- 6 files changed, 837 deletions(-) delete mode 100644 lib/src/new/dpop_key_manager(1).dart delete mode 100644 lib/src/new/dpop_key_manager.dart delete mode 100644 lib/src/new/dpop_token_generator.dart delete mode 100644 lib/src/new/solid_dpop_http_client.dart delete mode 100644 lib/src/new/solid_oidc_manager_factory.dart delete mode 100644 lib/src/profile/profile_fetcher.dart diff --git a/lib/src/new/dpop_key_manager(1).dart b/lib/src/new/dpop_key_manager(1).dart deleted file mode 100644 index 57cd227..0000000 --- a/lib/src/new/dpop_key_manager(1).dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; -import 'package:crypto/crypto.dart'; -import 'package:fast_rsa/fast_rsa.dart'; -import 'package:logging/logging.dart'; - -final _log = Logger('solid_auth.DpopKeyManager'); - -/// Manages the RSA key pair used for DPoP proofs. -/// -/// ## Why this must be created BEFORE authentication -/// -/// Solid-OIDC requires DPoP key binding at the **token endpoint** level -/// (RFC 9449 §5). The client MUST send a DPoP proof JWT as the `DPoP` -/// HTTP header on the token endpoint request. The OP then: -/// -/// 1. Validates the proof. -/// 2. Computes `jkt` = base64url(SHA-256(RFC 7638 JWK thumbprint)). -/// 3. Embeds `cnf: { jkt: "…" }` in the issued access token. -/// -/// A Resource Server verifying a `PATCH` / `GET` / etc. request later -/// checks that the DPoP proof was signed by the key whose thumbprint -/// matches `cnf.jkt` in the access token. -/// -/// If the token was issued WITHOUT a DPoP proof at token-request time -/// there is no `cnf` claim at all, and the RS returns: -/// -/// > "Expected object property cnf, got: [object Object]" -/// -/// **Fix**: generate the key pair here ONCE, inject it into every token -/// request via a `package:oidc` token hook, and reuse the same key pair -/// for all subsequent resource-level DPoP proofs. -class DpopKeyManager { - DpopKeyManager._({ - required this.keyPair, - required this.publicKeyJwk, - required this.jkt, - }); - - /// RSA-2048 key pair (PEM-encoded). - final KeyPair keyPair; - - /// Public key as a JWK map — embedded in every DPoP proof JWT header. - final Map publicKeyJwk; - - /// JWK thumbprint (RFC 7638, SHA-256, base64url, no padding). - /// - /// This is what the OP stores as `cnf.jkt` inside the access token. - /// Resource Servers use this value to confirm the proof was signed by - /// the same key that was presented at token-issuance time. - final String jkt; - - // ── Singleton ───────────────────────────────────────────────────────────── - - static DpopKeyManager? _instance; - - /// Returns the cached key manager, generating a fresh pair if none exists. - /// - /// Call **before** starting the auth flow so the key is ready when the - /// token-endpoint hook fires. - static Future getInstance() async { - return _instance ??= await _generate(); - } - - /// Generates a new RSA-2048 pair and replaces the cached instance. - /// - /// Use on logout or to rotate the DPoP binding key. - static Future rotate() async { - _instance = null; - return getInstance(); - } - - /// Clears the cached instance (call on logout). - static void clear() { - _instance = null; - } - - // ── Internal ────────────────────────────────────────────────────────────── - - static Future _generate() async { - _log.fine('Generating RSA-2048 DPoP key pair'); - final keyPair = await RSA.generate(2048); - final jwk = await _buildJwk(keyPair.publicKey); - final jkt = _computeJkt(jwk); - _log.fine('DPoP key pair ready — jkt: $jkt'); - return DpopKeyManager._(keyPair: keyPair, publicKeyJwk: jwk, jkt: jkt); - } - - /// Builds an RSA JWK from PEM. Only kty/n/e are strictly required for the - /// thumbprint; alg/use are added for RS compatibility. - static Future> _buildJwk(String publicKeyPem) async { - final jwkJson = await RSA.convertPublicKeyToJWK(publicKeyPem); - final raw = jsonDecode(jwkJson) as Map; - return { - 'kty': 'RSA', - 'use': 'sig', - 'alg': 'RS256', - if (raw['n'] != null) 'n': raw['n'], - if (raw['e'] != null) 'e': raw['e'], - }; - } - - /// RFC 7638 §3.2 — JWK thumbprint for RSA keys. - /// - /// Required members in lexicographic order: e, kty, n. - /// Result: base64url( SHA-256( UTF8( canonical JSON ) ) ), no padding. - static String _computeJkt(Map jwk) { - final canonical = jsonEncode({ - 'e': jwk['e'], - 'kty': jwk['kty'], - 'n': jwk['n'], - }); - final digest = sha256.convert(utf8.encode(canonical)); - return _base64UrlNoPad(Uint8List.fromList(digest.bytes)); - } - - static String _base64UrlNoPad(Uint8List bytes) => - base64Url.encode(bytes).replaceAll('=', ''); -} diff --git a/lib/src/new/dpop_key_manager.dart b/lib/src/new/dpop_key_manager.dart deleted file mode 100644 index 57cd227..0000000 --- a/lib/src/new/dpop_key_manager.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; -import 'package:crypto/crypto.dart'; -import 'package:fast_rsa/fast_rsa.dart'; -import 'package:logging/logging.dart'; - -final _log = Logger('solid_auth.DpopKeyManager'); - -/// Manages the RSA key pair used for DPoP proofs. -/// -/// ## Why this must be created BEFORE authentication -/// -/// Solid-OIDC requires DPoP key binding at the **token endpoint** level -/// (RFC 9449 §5). The client MUST send a DPoP proof JWT as the `DPoP` -/// HTTP header on the token endpoint request. The OP then: -/// -/// 1. Validates the proof. -/// 2. Computes `jkt` = base64url(SHA-256(RFC 7638 JWK thumbprint)). -/// 3. Embeds `cnf: { jkt: "…" }` in the issued access token. -/// -/// A Resource Server verifying a `PATCH` / `GET` / etc. request later -/// checks that the DPoP proof was signed by the key whose thumbprint -/// matches `cnf.jkt` in the access token. -/// -/// If the token was issued WITHOUT a DPoP proof at token-request time -/// there is no `cnf` claim at all, and the RS returns: -/// -/// > "Expected object property cnf, got: [object Object]" -/// -/// **Fix**: generate the key pair here ONCE, inject it into every token -/// request via a `package:oidc` token hook, and reuse the same key pair -/// for all subsequent resource-level DPoP proofs. -class DpopKeyManager { - DpopKeyManager._({ - required this.keyPair, - required this.publicKeyJwk, - required this.jkt, - }); - - /// RSA-2048 key pair (PEM-encoded). - final KeyPair keyPair; - - /// Public key as a JWK map — embedded in every DPoP proof JWT header. - final Map publicKeyJwk; - - /// JWK thumbprint (RFC 7638, SHA-256, base64url, no padding). - /// - /// This is what the OP stores as `cnf.jkt` inside the access token. - /// Resource Servers use this value to confirm the proof was signed by - /// the same key that was presented at token-issuance time. - final String jkt; - - // ── Singleton ───────────────────────────────────────────────────────────── - - static DpopKeyManager? _instance; - - /// Returns the cached key manager, generating a fresh pair if none exists. - /// - /// Call **before** starting the auth flow so the key is ready when the - /// token-endpoint hook fires. - static Future getInstance() async { - return _instance ??= await _generate(); - } - - /// Generates a new RSA-2048 pair and replaces the cached instance. - /// - /// Use on logout or to rotate the DPoP binding key. - static Future rotate() async { - _instance = null; - return getInstance(); - } - - /// Clears the cached instance (call on logout). - static void clear() { - _instance = null; - } - - // ── Internal ────────────────────────────────────────────────────────────── - - static Future _generate() async { - _log.fine('Generating RSA-2048 DPoP key pair'); - final keyPair = await RSA.generate(2048); - final jwk = await _buildJwk(keyPair.publicKey); - final jkt = _computeJkt(jwk); - _log.fine('DPoP key pair ready — jkt: $jkt'); - return DpopKeyManager._(keyPair: keyPair, publicKeyJwk: jwk, jkt: jkt); - } - - /// Builds an RSA JWK from PEM. Only kty/n/e are strictly required for the - /// thumbprint; alg/use are added for RS compatibility. - static Future> _buildJwk(String publicKeyPem) async { - final jwkJson = await RSA.convertPublicKeyToJWK(publicKeyPem); - final raw = jsonDecode(jwkJson) as Map; - return { - 'kty': 'RSA', - 'use': 'sig', - 'alg': 'RS256', - if (raw['n'] != null) 'n': raw['n'], - if (raw['e'] != null) 'e': raw['e'], - }; - } - - /// RFC 7638 §3.2 — JWK thumbprint for RSA keys. - /// - /// Required members in lexicographic order: e, kty, n. - /// Result: base64url( SHA-256( UTF8( canonical JSON ) ) ), no padding. - static String _computeJkt(Map jwk) { - final canonical = jsonEncode({ - 'e': jwk['e'], - 'kty': jwk['kty'], - 'n': jwk['n'], - }); - final digest = sha256.convert(utf8.encode(canonical)); - return _base64UrlNoPad(Uint8List.fromList(digest.bytes)); - } - - static String _base64UrlNoPad(Uint8List bytes) => - base64Url.encode(bytes).replaceAll('=', ''); -} diff --git a/lib/src/new/dpop_token_generator.dart b/lib/src/new/dpop_token_generator.dart deleted file mode 100644 index 2c40a9a..0000000 --- a/lib/src/new/dpop_token_generator.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; -import 'package:crypto/crypto.dart'; -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; -import 'package:fast_rsa/fast_rsa.dart'; -import 'package:uuid/uuid.dart'; -import 'package:logging/logging.dart'; - -import 'dpop_key_manager.dart'; - -final _log = Logger('solid_auth.DpopTokenGenerator'); -const _uuid = Uuid(); - -/// Generates DPoP (Demonstrating Proof-of-Possession) proof tokens per -/// RFC 9449 and the Solid-OIDC specification. -/// -/// ## Two kinds of DPoP proof -/// -/// ### 1. Token-endpoint proof (call [generateForTokenEndpoint]) -/// -/// Sent as the `DPoP` header on the token request to the OP. -/// The OP uses it to key-bind the issued access token by embedding -/// `cnf: { jkt: "" }` in the token payload. -/// -/// ``` -/// POST /token -/// DPoP: ← no `ath` claim here -/// Content-Type: application/x-www-form-urlencoded -/// ... -/// ``` -/// -/// ### 2. Resource-server proof (call [generateForRequest]) -/// -/// Sent alongside every protected-resource HTTP request. -/// The RS checks: -/// - `htm` matches the HTTP method. -/// - `htu` matches the request URL. -/// - `jti` has not been seen before (replay prevention). -/// - The proof is signed by the key whose thumbprint matches `cnf.jkt` -/// in the access token. -/// - `ath` = base64url(SHA-256(ASCII(access_token))). -/// -/// ``` -/// PATCH /resource -/// Authorization: DPoP -/// DPoP: ← includes `ath` claim -/// ``` -/// -/// The `cnf` error your Solid server returned means the access token was -/// issued WITHOUT step 1 — there was no DPoP proof on the token request. -abstract class DpopTokenGenerator { - DpopTokenGenerator._(); - - // ── Token-endpoint proof ─────────────────────────────────────────────────── - - /// Generates a DPoP proof for the **token endpoint request**. - /// - /// Must be sent as the `DPoP` header when calling the OP's token endpoint. - /// Do NOT include an `ath` claim here (there is no access token yet). - /// - /// [tokenEndpointUrl] — the OP token endpoint URI (e.g. - /// `https://solidcommunity.net/token`). - static Future generateForTokenEndpoint({ - required String tokenEndpointUrl, - DpopKeyManager? keyManager, - }) async { - final km = keyManager ?? await DpopKeyManager.getInstance(); - _log.fine('Generating DPoP token-endpoint proof for: $tokenEndpointUrl'); - return _build( - httpMethod: 'POST', - endpointUrl: tokenEndpointUrl, - keyPair: km.keyPair, - publicKeyJwk: km.publicKeyJwk, - accessToken: null, // no ath on token request - ); - } - - // ── Resource-server proof ────────────────────────────────────────────────── - - /// Generates a DPoP proof for a **protected resource request**. - /// - /// Automatically fetches the key pair from [DpopKeyManager.getInstance]. - /// The [accessToken] is required — it is hashed into the `ath` claim which - /// binds the proof to the specific token being used. - static Future generateForRequest({ - required String endpointUrl, - required String httpMethod, - required String accessToken, - DpopKeyManager? keyManager, - }) async { - final km = keyManager ?? await DpopKeyManager.getInstance(); - _log.fine('Generating DPoP resource proof: $httpMethod $endpointUrl'); - return _build( - httpMethod: httpMethod, - endpointUrl: endpointUrl, - keyPair: km.keyPair, - publicKeyJwk: km.publicKeyJwk, - accessToken: accessToken, - ); - } - - // ── Legacy-compatible static method ──────────────────────────────────────── - - /// Drop-in replacement for the old `genDpopToken(url, keyPair, jwk, method)`. - /// - /// Provide [accessToken] for resource requests (adds `ath` claim). - /// Omit it when generating a token-endpoint proof. - static String generate({ - required String endpointUrl, - required KeyPair keyPair, - required Map publicKeyJwk, - required String httpMethod, - String? accessToken, - }) { - return _build( - httpMethod: httpMethod, - endpointUrl: endpointUrl, - keyPair: keyPair, - publicKeyJwk: publicKeyJwk, - accessToken: accessToken, - ); - } - - // ── Core builder ─────────────────────────────────────────────────────────── - - static String _build({ - required String httpMethod, - required String endpointUrl, - required KeyPair keyPair, - required Map publicKeyJwk, - String? accessToken, - }) { - final payload = { - 'jti': _uuid.v4(), - 'htm': httpMethod.toUpperCase(), - 'htu': endpointUrl, - 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, - }; - - // ath = base64url(SHA-256(ASCII(access_token))) per RFC 9449 §4.2 - // Required on resource requests; MUST be absent on token-endpoint proofs. - if (accessToken != null && accessToken.isNotEmpty) { - payload['ath'] = _sha256Base64Url(accessToken); - } - - final jwt = JWT( - payload, - header: JWTHeader( - algorithm: JWTAlgorithm.RS256, - typ: 'dpop+jwt', - // The public key in JWK form is embedded directly in the JWT header. - // This allows the RS to verify the signature without a key lookup. - extra: {'jwk': publicKeyJwk}, - ), - ); - - return jwt.sign(RSAPrivateKey(keyPair.privateKey)); - } - - // ── Helpers ──────────────────────────────────────────────────────────────── - - /// base64url( SHA-256( ASCII( token ) ) ), no padding. - static String _sha256Base64Url(String token) { - final bytes = utf8.encode(token); // ASCII subset is valid UTF-8 - final digest = sha256.convert(bytes); - return base64Url - .encode(Uint8List.fromList(digest.bytes)) - .replaceAll('=', ''); - } -} diff --git a/lib/src/new/solid_dpop_http_client.dart b/lib/src/new/solid_dpop_http_client.dart deleted file mode 100644 index 68b7178..0000000 --- a/lib/src/new/solid_dpop_http_client.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:logging/logging.dart'; - -import '../dpop/dpop_key_manager.dart'; -import '../dpop/dpop_token_generator.dart'; - -final _log = Logger('solid_auth.SolidDpopHttpClient'); - -/// An [http.BaseClient] that automatically injects a DPoP proof header on -/// requests to the token endpoint. -/// -/// ## Why a custom HTTP client? -/// -/// `package:oidc` accepts a custom `http.Client` via -/// `OidcUserManager(httpClient: ...)`. Every HTTP call the manager makes — -/// including the token endpoint POST — goes through this client. -/// -/// We detect token-endpoint calls by checking whether the request URL's path -/// ends with the [tokenEndpointPath] segment (or matches [tokenEndpointUri] -/// exactly if provided) and inject a fresh `DPoP` header on those requests. -/// -/// For all other requests the call is forwarded unchanged. -/// -/// ## Result -/// -/// With this client wired in, the token endpoint receives a valid DPoP proof -/// on every token request. The OP responds with an access token that includes -/// `cnf: { jkt: "…" }`, allowing the Resource Server to verify subsequent -/// DPoP-bound resource requests. -class SolidDpopHttpClient extends http.BaseClient { - SolidDpopHttpClient({ - required this.keyManager, - http.Client? inner, - this.tokenEndpointUri, - }) : _inner = inner ?? http.Client(); - - /// The DPoP key manager supplying the key pair and JWK. - final DpopKeyManager keyManager; - - /// Optional: exact URI of the token endpoint. - /// When set, only requests to this exact URI get DPoP headers. - /// When null, any request whose path contains `/token` is treated as a - /// token-endpoint call (works for all known Solid providers). - final Uri? tokenEndpointUri; - - final http.Client _inner; - - @override - Future send(http.BaseRequest request) async { - if (_isTokenEndpoint(request.url)) { - return _sendWithDpop(request); - } - return _inner.send(request); - } - - Future _sendWithDpop( - http.BaseRequest request, - ) async { - final dpopProof = await DpopTokenGenerator.generateForTokenEndpoint( - tokenEndpointUrl: _normalizedUrl(request.url), - keyManager: keyManager, - ); - - _log.fine('Injecting DPoP header on token request: ${request.url}'); - - // Clone the request and add the DPoP header. - // We must copy it because BaseRequest can only be sent once. - final copy = _copyRequest(request); - copy.headers['DPoP'] = dpopProof; - return _inner.send(copy); - } - - bool _isTokenEndpoint(Uri url) { - if (tokenEndpointUri != null) { - return url == tokenEndpointUri; - } - // Heuristic: Solid providers universally use a path ending in /token. - return url.path.endsWith('/token') || url.path.contains('/token?'); - } - - /// Strips query parameters from the URL for the `htu` claim. - /// RFC 9449 §4.2: htu MUST NOT include query or fragment components. - static String _normalizedUrl(Uri url) => - url.replace(query: '', fragment: '').toString(); - - /// Copies a [http.BaseRequest] (Request or StreamedRequest) into a - /// fresh [http.Request] with the same method, URL, headers, and body. - static http.Request _copyRequest(http.BaseRequest original) { - final copy = http.Request(original.method, original.url) - ..headers.addAll(original.headers) - ..followRedirects = original.followRedirects - ..maxRedirects = original.maxRedirects - ..persistentConnection = original.persistentConnection; - - if (original is http.Request) { - copy.bodyBytes = original.bodyBytes; - } - return copy; - } - - @override - void close() { - _inner.close(); - } -} diff --git a/lib/src/new/solid_oidc_manager_factory.dart b/lib/src/new/solid_oidc_manager_factory.dart deleted file mode 100644 index 9fe9ced..0000000 --- a/lib/src/new/solid_oidc_manager_factory.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:oidc/oidc.dart'; -import 'package:oidc_default_store/oidc_default_store.dart'; -import 'package:logging/logging.dart'; - -import '../dpop/dpop_key_manager.dart'; -import '../models/solid_provider_metadata.dart'; -import '../utils/solid_scopes.dart'; -import 'solid_dpop_http_client.dart'; - -final _log = Logger('solid_auth.SolidOidcManagerFactory'); - -/// Configuration for building an [OidcUserManager] for Solid-OIDC. -class SolidOidcConfig { - const SolidOidcConfig({ - required this.clientId, - required this.redirectUri, - this.postLogoutRedirectUri, - this.scopes = SolidScopes.defaultScopes, - this.clientSecret, - this.httpClient, - this.extraTokenParameters, - this.extraAuthParameters, - }); - - final String clientId; - final Uri redirectUri; - final Uri? postLogoutRedirectUri; - final List scopes; - final String? clientSecret; - - /// Optional base HTTP client. Wrapped internally by [SolidDpopHttpClient]. - /// You do NOT need to add DPoP logic here — that is handled automatically. - final http.Client? httpClient; - - final Map? extraTokenParameters; - final Map? extraAuthParameters; -} - -/// Factory that constructs a fully configured [OidcUserManager] for -/// Solid-OIDC, with automatic DPoP key binding at the token endpoint. -/// -/// ## What changed vs the previous version -/// -/// The previous factory created an [OidcUserManager] with a plain HTTP client, -/// so token requests were sent without a `DPoP` header. The OP therefore issued -/// plain Bearer tokens (no `cnf` claim), and the Resource Server rejected them: -/// -/// > "Expected object property cnf, got: [object Object]" -/// -/// The fix is to wrap the HTTP client with [SolidDpopHttpClient], which -/// automatically injects a fresh DPoP proof on every request to the token -/// endpoint. The same [DpopKeyManager] instance is returned alongside the -/// manager so it can be reused for resource-request proofs. -abstract class SolidOidcManagerFactory { - SolidOidcManagerFactory._(); - - /// Creates an [OidcUserManager] and [DpopKeyManager], both pre-configured - /// for Solid-OIDC with automatic token-endpoint DPoP injection. - /// - /// [metadata] is optional — pass it to skip the network discovery call. - /// - /// Returns a record `(manager, keyManager)`. The [DpopKeyManager] MUST be - /// reused when generating DPoP proofs for resource requests so the key pair - /// stays consistent with the `cnf.jkt` embedded in the access token. - static Future<({OidcUserManager manager, DpopKeyManager keyManager})> create({ - required String issuerUri, - required SolidOidcConfig config, - SolidProviderMetadata? metadata, - }) async { - _log.fine('Creating OidcUserManager for issuer: $issuerUri'); - - final scopes = _ensureWebIdScope(config.scopes); - - // 1. Obtain (or generate) the DPoP key pair BEFORE the manager is used. - // The key pair must exist before the first token-endpoint call so the - // SolidDpopHttpClient can sign the proof. - final keyManager = await DpopKeyManager.getInstance(); - - // 2. Wrap the HTTP client so token requests get an injected DPoP header. - final dpopClient = SolidDpopHttpClient( - keyManager: keyManager, - inner: config.httpClient, - ); - - final clientAuth = config.clientSecret != null - ? OidcClientAuthentication.clientSecretPost( - clientId: config.clientId, - clientSecret: config.clientSecret!, - ) - : OidcClientAuthentication.none(clientId: config.clientId); - - final settings = OidcUserManagerSettings( - redirectUri: config.redirectUri, - postLogoutRedirectUri: config.postLogoutRedirectUri, - scope: scopes, - extraAuthenticationParameters: config.extraAuthParameters ?? {}, - extraTokenParameters: config.extraTokenParameters ?? {}, - ); - - final manager = metadata != null - ? OidcUserManager( - discoveryDocument: metadata.oidcMetadata, - clientCredentials: clientAuth, - store: OidcDefaultStore(), - settings: settings, - httpClient: dpopClient, // ← DPoP-aware client - ) - : OidcUserManager.lazy( - discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri( - Uri.parse(issuerUri), - ), - clientCredentials: clientAuth, - store: OidcDefaultStore(), - settings: settings, - httpClient: dpopClient, // ← DPoP-aware client - ); - - return (manager: manager, keyManager: keyManager); - } - - static List _ensureWebIdScope(List scopes) { - if (scopes.contains(SolidScopes.webid)) return scopes; - _log.warning( - 'webid scope missing — adding automatically (Solid-OIDC requirement)', - ); - return [...scopes, SolidScopes.webid]; - } -} diff --git a/lib/src/profile/profile_fetcher.dart b/lib/src/profile/profile_fetcher.dart deleted file mode 100644 index e3f4283..0000000 --- a/lib/src/profile/profile_fetcher.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:logging/logging.dart'; - -final _log = Logger('solid_auth.ProfileFetcher'); - -/// Fetches and parses a Solid POD's public profile document from a WebID URI. -/// -/// Replaces the old `fetchProfileData(webId)` function. The profile document -/// is typically served as Turtle or JSON-LD. This class returns the raw body -/// alongside a lightly-parsed [SolidProfile] for the most common fields. -/// -/// For full RDF parsing, consumers should use a Turtle/JSON-LD library such -/// as `rdf_mapper` or `solid_flutter`. -class ProfileFetcher { - const ProfileFetcher({http.Client? httpClient}) - : _httpClient = httpClient; - - final http.Client? _httpClient; - - /// Fetches the profile document for [webId] and returns a [SolidProfile]. - /// - /// Negotiates `application/ld+json` first, then `text/turtle`. - Future fetchProfile(String webId) async { - _log.fine('Fetching profile for: $webId'); - - final client = _httpClient ?? http.Client(); - final ownClient = _httpClient == null; - - try { - final response = await client.get( - Uri.parse(webId), - headers: {'Accept': 'application/ld+json, text/turtle;q=0.9'}, - ); - - if (response.statusCode != 200) { - throw ProfileFetchException( - 'HTTP ${response.statusCode} fetching profile for $webId', - webId: webId, - statusCode: response.statusCode, - ); - } - - final contentType = response.headers['content-type'] ?? ''; - return SolidProfile._parse( - webId: webId, - body: response.body, - contentType: contentType, - ); - } finally { - if (ownClient) client.close(); - } - } -} - -// ── Model ────────────────────────────────────────────────────────────────── - -/// Lightweight representation of a Solid POD public profile. -/// -/// Contains the raw document body plus the fields most commonly needed -/// by Solid apps. For complete RDF access, parse [rawBody] directly. -class SolidProfile { - const SolidProfile({ - required this.webId, - required this.rawBody, - required this.contentType, - this.name, - this.storage, - this.oidcIssuer, - this.inbox, - }); - - /// The WebID URI this profile belongs to. - final String webId; - - /// The raw profile document body (Turtle or JSON-LD). - final String rawBody; - - /// The MIME type of the profile document. - final String contentType; - - /// `foaf:name` or `vcard:fn`, if found. - final String? name; - - /// `pim:storage` — the root container URI of the user's POD, if advertised. - final Uri? storage; - - /// `solid:oidcIssuer` — the identity provider URI, if advertised. - final Uri? oidcIssuer; - - /// `ldp:inbox` — the user's LDP inbox URI, if advertised. - final Uri? inbox; - - factory SolidProfile._parse({ - required String webId, - required String body, - required String contentType, - }) { - String? name; - Uri? storage; - Uri? oidcIssuer; - Uri? inbox; - - if (contentType.contains('json')) { - // JSON-LD path - try { - final doc = jsonDecode(body); - final nodes = doc is List ? doc : [doc]; - for (final node in nodes) { - if (node is Map) { - name ??= _jsonLdValue(node, 'http://xmlns.com/foaf/0.1/name') ?? - _jsonLdValue( - node, 'http://www.w3.org/2006/vcard/ns#fn'); - storage ??= _jsonLdUri( - node, 'http://www.w3.org/ns/pim/space#storage'); - oidcIssuer ??= _jsonLdUri( - node, 'http://www.w3.org/ns/solid/terms#oidcIssuer'); - inbox ??= - _jsonLdUri(node, 'http://www.w3.org/ns/ldp#inbox'); - } - } - } catch (e) { - _log.warning('Failed to parse JSON-LD profile: $e'); - } - } else { - // Naive Turtle scan (no full RDF parsing). - name = _turtleValue(body, r'foaf:name|vcard:fn'); - storage = _turtleUri(body, r'pim:storage|space:storage'); - oidcIssuer = _turtleUri(body, r'solid:oidcIssuer'); - inbox = _turtleUri(body, r'ldp:inbox'); - } - - return SolidProfile( - webId: webId, - rawBody: body, - contentType: contentType, - name: name, - storage: storage, - oidcIssuer: oidcIssuer, - inbox: inbox, - ); - } - - // ── JSON-LD helpers ──────────────────────────────────────────────────────── - - static String? _jsonLdValue(Map node, String predicate) { - final entry = node[predicate]; - if (entry is List && entry.isNotEmpty) { - final v = entry.first; - if (v is Map) return (v['@value'] ?? v['@id']) as String?; - } - return null; - } - - static Uri? _jsonLdUri(Map node, String predicate) { - final val = _jsonLdValue(node, predicate); - return val != null ? Uri.tryParse(val) : null; - } - - // ── Turtle helpers (naive regex — good enough for well-formed profiles) ─── - - static String? _turtleValue(String body, String predicatePattern) { - final pattern = RegExp( - '(?:$predicatePattern)\\s+"([^"]+)"', - caseSensitive: false, - ); - return pattern.firstMatch(body)?.group(1); - } - - static Uri? _turtleUri(String body, String predicatePattern) { - final pattern = RegExp( - '(?:$predicatePattern)\\s+<([^>]+)>', - caseSensitive: false, - ); - final match = pattern.firstMatch(body); - return match != null ? Uri.tryParse(match.group(1)!) : null; - } -} - -// ── Exception ───────────────────────────────────────────────────────────── - -class ProfileFetchException implements Exception { - const ProfileFetchException( - this.message, { - required this.webId, - this.statusCode, - }); - - final String message; - final String webId; - final int? statusCode; - - @override - String toString() => 'ProfileFetchException($webId): $message'; -} From 9ca4ae760dd20d6e65b7887593d29acd7a9be34b Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Mon, 18 May 2026 09:01:01 +1000 Subject: [PATCH 07/14] update license --- lib/solid_auth.dart | 39 +++++++++++++++++---- lib/src/models/solid_auth_data.dart | 29 +++++++++++++++ lib/src/models/solid_provider_metadata.dart | 34 ++++++++++++++++-- lib/src/utils/solid_scopes.dart | 29 +++++++++++++++ lib/src/utils/webid_utils.dart | 29 +++++++++++++++ 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/lib/solid_auth.dart b/lib/solid_auth.dart index 69d071b..0faf197 100644 --- a/lib/solid_auth.dart +++ b/lib/solid_auth.dart @@ -1,4 +1,34 @@ -/// Solid Auth — Solid-OIDC authentication for Flutter, built on package:oidc. +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage + +/// Solid Auth — Solid-OIDC authentication flow for Flutter +/// Built on package:oidc (https://pub.dev/packages/oidc) +/// Inspired by the package (https://pub.dev/packages/solid_oidc_auth) /// /// Main entry point. Import this file to access the public API: /// @@ -11,17 +41,14 @@ library solid_auth; export 'src/models/solid_auth_data.dart'; export 'src/models/solid_provider_metadata.dart'; -// Core auth facade — the primary API consumers interact with +// Core auth functionality. The primary API consumers interact with export 'src/auth/solid_auth_manager.dart'; export 'src/auth/solid_oidc_manager_factory.dart'; -// DPoP token generation (unchanged from current solid_auth API) +// DPoP token generation export 'src/dpop/dpop_token_generator.dart'; export 'src/dpop/dpop_key_manager.dart'; -// POD profile access -export 'src/profile/profile_fetcher.dart'; - // Utilities export 'src/utils/webid_utils.dart'; export 'src/utils/solid_scopes.dart'; diff --git a/lib/src/models/solid_auth_data.dart b/lib/src/models/solid_auth_data.dart index b29b670..8ace21a 100644 --- a/lib/src/models/solid_auth_data.dart +++ b/lib/src/models/solid_auth_data.dart @@ -1,3 +1,32 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage +library; + /// Data returned after a successful Solid-OIDC authentication. /// /// Replaces the raw `Map` previously returned by diff --git a/lib/src/models/solid_provider_metadata.dart b/lib/src/models/solid_provider_metadata.dart index cba6622..fd4a06f 100644 --- a/lib/src/models/solid_provider_metadata.dart +++ b/lib/src/models/solid_provider_metadata.dart @@ -1,3 +1,32 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage +library; + import 'package:oidc_core/oidc_core.dart'; /// Extends the standard OpenID Connect discovery document with @@ -46,9 +75,8 @@ class SolidProviderMetadata { registrationEndpoint: json['registration_endpoint'] != null ? Uri.parse(json['registration_endpoint'] as String) : null, - storageEndpoint: json['storage'] != null - ? Uri.parse(json['storage'] as String) - : null, + storageEndpoint: + json['storage'] != null ? Uri.parse(json['storage'] as String) : null, ); } } diff --git a/lib/src/utils/solid_scopes.dart b/lib/src/utils/solid_scopes.dart index ac46ef3..e51dea4 100644 --- a/lib/src/utils/solid_scopes.dart +++ b/lib/src/utils/solid_scopes.dart @@ -1,3 +1,32 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage +library; + /// Scope constants used in Solid-OIDC authentication requests. /// /// Standard OpenID Connect scopes plus the `webid` scope mandated by the diff --git a/lib/src/utils/webid_utils.dart b/lib/src/utils/webid_utils.dart index bff9208..6e1fe9d 100644 --- a/lib/src/utils/webid_utils.dart +++ b/lib/src/utils/webid_utils.dart @@ -1,3 +1,32 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage +library; + import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; From 2076495c35e1e384efe8c246bd615f3ae23a5bfc Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Mon, 18 May 2026 09:01:16 +1000 Subject: [PATCH 08/14] add dPoP changes --- lib/src/auth/solid_auth_manager.dart | 237 ++++++++++++++----- lib/src/auth/solid_oidc_manager_factory.dart | 172 +++++++++++--- lib/src/dpop/dpop_key_manager.dart | 157 +++++++++--- lib/src/dpop/dpop_token_generator.dart | 157 +++++++++--- 4 files changed, 555 insertions(+), 168 deletions(-) diff --git a/lib/src/auth/solid_auth_manager.dart b/lib/src/auth/solid_auth_manager.dart index ae885b3..17d1ab8 100644 --- a/lib/src/auth/solid_auth_manager.dart +++ b/lib/src/auth/solid_auth_manager.dart @@ -1,21 +1,61 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage +library; + import 'package:http/http.dart' as http; import 'package:oidc/oidc.dart'; import 'package:logging/logging.dart'; -import '../models/solid_auth_data.dart'; -import '../models/solid_provider_metadata.dart'; -import '../utils/solid_scopes.dart'; -import '../utils/webid_utils.dart'; -import 'solid_oidc_manager_factory.dart'; +import 'package:solid_auth/src/dpop/dpop_key_manager.dart'; +import 'package:solid_auth/src/models/solid_auth_data.dart'; +import 'package:solid_auth/src/models/solid_provider_metadata.dart'; +import 'package:solid_auth/src/utils/solid_scopes.dart'; +import 'package:solid_auth/src/utils/webid_utils.dart'; +import 'package:solid_auth/src/auth/solid_oidc_manager_factory.dart'; final _log = Logger('solid_auth.SolidAuthManager'); /// High-level facade for Solid-OIDC authentication. /// -/// This is the primary class consumers interact with. It replaces the old -/// free-standing `authenticate()` function with a stateful, lifecycle-aware -/// manager that correctly handles token refresh, logout, and user-change -/// streams. +/// Wraps [OidcUserManager] from `package:oidc` and adds Solid-specific +/// concerns: WebID-based issuer discovery, mandatory `webid` scope, and +/// DPoP key management. +/// +/// ## DPoP flow +/// +/// 1. [SolidOidcManagerFactory.create] pre-generates an RSA key pair via +/// [DpopKeyManager] and registers an [OidcHook] that automatically injects +/// a DPoP proof header on every token-endpoint request. +/// 2. The Solid OP binds the returned access token to the key pair by +/// embedding `cnf: { jkt: "" }` in it. +/// 3. When you access a protected resource, call +/// [DpopTokenGenerator.generateForRequest] with the stored [keyManager] +/// to produce a matching proof. /// /// ## Quick start /// @@ -27,11 +67,17 @@ final _log = Logger('solid_auth.SolidAuthManager'); /// ), /// ); /// -/// // Resolve the issuer from a WebID, then authenticate. /// final data = await auth.loginFromWebId( -/// 'https://charlieb.solidcommunity.net/profile/card#me', +/// 'https://alice.solidcommunity.net/profile/card#me', +/// ); +/// +/// // Accessing a protected resource: +/// final dpopProof = await DpopTokenGenerator.generateForRequest( +/// endpointUrl: 'https://alice.solidcommunity.net/private/notes.ttl', +/// httpMethod: 'PATCH', +/// accessToken: data.accessToken, +/// keyManager: auth.keyManager, // same key pair used at auth time /// ); -/// print(data.webId); // https://charlieb.solidcommunity.net/profile/card#me /// ``` /// /// ## Migration from solid_auth 0.1.x @@ -43,6 +89,7 @@ final _log = Logger('solid_auth.SolidAuthManager'); /// | `authData['accessToken']` | `SolidAuthData.accessToken` | /// | `authData['idToken']` | `SolidAuthData.idToken` | /// | `genDpopToken(...)` | `DpopTokenGenerator.generate(...)` | +/// class SolidAuthManager { SolidAuthManager({ required this.config, @@ -54,6 +101,26 @@ class SolidAuthManager { OidcUserManager? _oidcManager; + /// The [DpopKeyManager] created during [initForIssuer]. + /// + /// **Always pass this to [DpopTokenGenerator.generateForRequest]** when + /// accessing protected resources, so the resource-level DPoP proof is signed + /// by the same key whose thumbprint (`jkt`) is embedded in the access token. + DpopKeyManager? _keyManager; + + /// Exposes the active [DpopKeyManager] for resource-request proof generation. + /// + /// Throws if called before [initForIssuer] / [login] / [loginFromWebId]. + DpopKeyManager get keyManager { + if (_keyManager == null) { + throw StateError( + 'SolidAuthManager has not been initialised. ' + 'Call loginFromWebId() or initForIssuer() first.', + ); + } + return _keyManager!; + } + /// The underlying [OidcUserManager] once initialised. /// Exposed for callers that need fine-grained access to oidc internals. OidcUserManager get oidcManager { @@ -66,19 +133,22 @@ class SolidAuthManager { return _oidcManager!; } - // ── Issuer-aware login ──────────────────────────────────────────────────── + /// ### Issuer-aware login - /// Resolves the OIDC issuer from [webId], initialises the underlying - /// [OidcUserManager], then triggers the Authorization Code + PKCE flow. + /// Resolves the OIDC issuer from [webIdOrIssuerUri], if the given value is a + /// Web ID or returns the issuer value as is, initialises the underlying + /// [OidcUserManager] with DPoP key binding, then triggers the + /// Authorization Code + PKCE flow. /// /// Returns a [SolidAuthData] with the tokens and extracted WebID on success. - Future loginFromWebId( - String webId, { + Future authenticate( + String webIdOrIssuerUri, { List? scopeOverride, }) async { - _log.info('Starting Solid-OIDC login for WebID: $webId'); + _log.info('Starting Solid-OIDC login for: $webIdOrIssuerUri'); - final issuerUri = await WebIdUtils.getIssuer(webId, httpClient: httpClient); + final issuerUri = + await WebIdUtils.getIssuer(webIdOrIssuerUri, httpClient: httpClient); return login(issuerUri: issuerUri, scopeOverride: scopeOverride); } @@ -89,62 +159,90 @@ class SolidAuthManager { required String issuerUri, List? scopeOverride, }) async { - await initForIssuer(issuerUri); + await initForIssuer( + issuerUri, + scopeOverride: scopeOverride, + ); - final effectiveConfig = - scopeOverride != null ? _configWithScopes(scopeOverride) : config; + // final effectiveConfig = + // scopeOverride != null ? _configWithScopes(scopeOverride) : config; - // Re-create the manager if scopes differ. - if (scopeOverride != null) { - _oidcManager = SolidOidcManagerFactory.create( - issuerUri: issuerUri, - config: effectiveConfig, - ); - await _oidcManager!.init(); - } + // // Re-create the manager if scopes differ. + // if (scopeOverride != null) { + // _oidcManager = SolidOidcManagerFactory.create( + // issuerUri: issuerUri, + // config: effectiveConfig, + // ); + // await _oidcManager!.init(); + // } _log.fine('Launching Authorization Code + PKCE flow'); - print('now1'); final user = await _oidcManager!.loginAuthorizationCodeFlow(); if (user == null) { throw const SolidAuthTokenException('Login cancelled or failed.'); } - print('there is a user '); - print(user); - return _mapUserToAuthData(user, issuerUri); } // ── Lifecycle ───────────────────────────────────────────────────────────── - /// Initialises the [OidcUserManager] for [issuerUri] without triggering - /// login. Useful for restoring a persisted session on app start: + /// Initialises the [OidcUserManager] (and the [DpopKeyManager]) for + /// [issuerUri] without triggering login. + /// + /// Useful for restoring a persisted session on app start: /// /// ```dart /// await auth.initForIssuer('https://solidcommunity.net'); /// if (auth.currentAuthData != null) { - /// // Session restored from store — user is already logged in. + /// // Session restored — user is already logged in. /// } /// ``` - Future initForIssuer(String issuerUri) async { - if (_oidcManager == null || - _oidcManager!.discoveryDocument?.issuer.toString() != issuerUri) { - _log.fine('Initialising OidcUserManager for issuer: $issuerUri'); - _oidcManager = SolidOidcManagerFactory.create( - issuerUri: issuerUri, - config: config, - ); - await _oidcManager!.init(); - _log.fine('OidcUserManager ready'); + Future initForIssuer( + String issuerUri, { + List? scopeOverride, + SolidProviderMetadata? metadata, + }) async { + final currentIssuer = _oidcManager?.discoveryDocument.issuer.toString(); + + if (_oidcManager != null && + currentIssuer == issuerUri && + scopeOverride == null) { + return; // already initialised for this issuer with default scopes } + + _log.fine('Initialising OidcUserManager for issuer: $issuerUri'); + + final effectiveConfig = + scopeOverride != null ? _configWithScopes(scopeOverride) : config; + + // SolidOidcManagerFactory.create returns a named record: + // (manager: OidcUserManager, keyManager: DpopKeyManager) + // + // The factory: + // 1. Calls DpopKeyManager.getInstance() to obtain/generate the key pair. + // 2. Registers an OidcHook(modifyRequest: ...) that injects a fresh + // DPoP proof on every token-endpoint POST. + final (:manager, :keyManager) = await SolidOidcManagerFactory.create( + issuerUri: issuerUri, + config: effectiveConfig, + metadata: metadata, + ); + + _oidcManager = manager; + _keyManager = keyManager; + + await _oidcManager!.init(); + _log.fine('OidcUserManager ready!'); } /// Logs out the user from the identity provider and clears local tokens. Future logout() async { _log.info('Logging out'); await _oidcManager?.logout(); + DpopKeyManager.clear(); // rotate key on logout for forward secrecy + _keyManager = null; } /// Clears local token state without contacting the identity provider. @@ -157,6 +255,7 @@ class SolidAuthManager { Future dispose() async { await _oidcManager?.dispose(); _oidcManager = null; + _keyManager = null; } // ── Token access ────────────────────────────────────────────────────────── @@ -168,7 +267,7 @@ class SolidAuthManager { if (user == null) return null; return _mapUserToAuthData( user, - _oidcManager?.discoveryDocument?.issuer.toString() ?? '', + _oidcManager?.discoveryDocument.issuer.toString() ?? '', ); } @@ -181,7 +280,7 @@ class SolidAuthManager { ? null : _mapUserToAuthData( user, - oidcManager.discoveryDocument?.issuer.toString() ?? '', + oidcManager.discoveryDocument.issuer.toString(), ), ); } @@ -193,32 +292,52 @@ class SolidAuthManager { if (user == null) return null; return _mapUserToAuthData( user, - _oidcManager?.discoveryDocument?.issuer.toString() ?? '', + _oidcManager?.discoveryDocument.issuer.toString() ?? '', ); } + /// 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 _oidcManager != null && _oidcManager!.currentUser != null; + } + // ── Internal helpers ─────────────────────────────────────────────────────── SolidAuthData _mapUserToAuthData(OidcUser user, String issuerUri) { - print('mapping user to auth data'); final token = user.token; - final claims = user.aggregatedClaims ?? {}; - - print('here1'); + final claims = user.aggregatedClaims; final accessToken = token.accessToken; final idToken = token.idToken ?? ''; final refreshToken = token.refreshToken; final webId = _extractWebId(claims) ?? user.uid ?? ''; - print('here2'); - print(token.expiresIn); - // Derive expiry: prefer explicit expiresAt, fall back to now + expires_in. final expiresAt = DateTime.now().add(token.expiresIn!); - print('here3'); - return SolidAuthData( accessToken: accessToken ?? '', idToken: idToken, diff --git a/lib/src/auth/solid_oidc_manager_factory.dart b/lib/src/auth/solid_oidc_manager_factory.dart index 80782da..6a71c6f 100644 --- a/lib/src/auth/solid_oidc_manager_factory.dart +++ b/lib/src/auth/solid_oidc_manager_factory.dart @@ -1,10 +1,41 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage +library; + import 'package:http/http.dart' as http; import 'package:oidc/oidc.dart'; import 'package:oidc_default_store/oidc_default_store.dart'; import 'package:logging/logging.dart'; -import '../models/solid_provider_metadata.dart'; -import '../utils/solid_scopes.dart'; +import 'package:solid_auth/src/dpop/dpop_key_manager.dart'; +import 'package:solid_auth/src/dpop/dpop_token_generator.dart'; +import 'package:solid_auth/src/models/solid_provider_metadata.dart'; +import 'package:solid_auth/src/utils/solid_scopes.dart'; final _log = Logger('solid_auth.SolidOidcManagerFactory'); @@ -78,56 +109,125 @@ abstract class SolidOidcManagerFactory { /// /// [metadata] is optional — pass it if you have already fetched the /// discovery document to avoid an extra network round-trip. - static OidcUserManager create({ + static Future<({OidcUserManager manager, DpopKeyManager keyManager})> create({ required String issuerUri, required SolidOidcConfig config, SolidProviderMetadata? metadata, - }) { + }) async { _log.fine('Creating OidcUserManager for issuer: $issuerUri'); // Ensure webid scope is always present (Solid-OIDC requirement). final scopes = _ensureWebIdScope(config.scopes); - final clientAuth = config.clientSecret != null - ? OidcClientAuthentication.clientSecretPost( - clientId: config.clientId, - clientSecret: config.clientSecret!, - ) - : OidcClientAuthentication.none(clientId: config.clientId); + // 1. Generate (or reuse) the DPoP key pair BEFORE the manager is used. + // The key must exist before the first token-endpoint call so the hook + // can sign the proof. + final keyManager = await DpopKeyManager.getInstance(); + + // 2. Build the DPoP injection hook using OidcHook.modifyRequest. + // + // OidcTokenHookRequest exposes: + // .request — OidcTokenRequest (has .grantType, .tokenEndpoint, etc.) + // .headers — Map, mutated in place before the HTTP + // call is fired. + // + // We inject a fresh DPoP proof on every token request (authorization_code, + // refresh_token, etc.) because the Solid OP requires it each time. + final dpopTokenHook = OidcHook( + modifyRequest: (hookRequest) async { + final tokenEndpointUrl = hookRequest.tokenEndpoint.toString(); + + _log.fine( + 'DPoP hook: generating proof for token endpoint: $tokenEndpointUrl ' + '(grant_type=${hookRequest.request.grantType})', + ); + + final dpopProof = await DpopTokenGenerator.generateForTokenEndpoint( + tokenEndpointUrl: tokenEndpointUrl, + keyManager: keyManager, + ); + + // Mutate the headers map in place — OidcUserManagerBase reads it + // after modifyRequest returns and includes it in the HTTP POST. + hookRequest.headers!['DPoP'] = dpopProof; + + return hookRequest; + }, + ); + // 3. Wire the hook into OidcUserManagerSettings. final settings = OidcUserManagerSettings( redirectUri: config.redirectUri, postLogoutRedirectUri: config.postLogoutRedirectUri, scope: scopes, - extraAuthenticationParameters: { - // Solid-OIDC requires PKCE; package:oidc uses it by default for - // the Authorization Code flow, so no extra wiring is needed. - ...?config.extraAuthParameters, - }, + extraAuthenticationParameters: config.extraAuthParameters ?? {}, extraTokenParameters: config.extraTokenParameters ?? {}, - ); - - if (metadata != null) { - // Use the pre-fetched discovery document to skip a network call. - return OidcUserManager( - discoveryDocument: metadata.oidcMetadata, - clientCredentials: clientAuth, - store: OidcDefaultStore(), - settings: settings, - httpClient: config.httpClient, - ); - } - - // Lazy path: let package:oidc fetch the discovery document on init(). - return OidcUserManager.lazy( - discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri( - Uri.parse(issuerUri), + hooks: OidcUserManagerHooks( + token: dpopTokenHook, ), - clientCredentials: clientAuth, - store: OidcDefaultStore(), - settings: settings, - httpClient: config.httpClient, ); + + final clientAuth = config.clientSecret != null + ? OidcClientAuthentication.clientSecretPost( + clientId: config.clientId, + clientSecret: config.clientSecret!, + ) + : OidcClientAuthentication.none(clientId: config.clientId); + + // final settings = OidcUserManagerSettings( + // redirectUri: config.redirectUri, + // postLogoutRedirectUri: config.postLogoutRedirectUri, + // scope: scopes, + // extraAuthenticationParameters: { + // // Solid-OIDC requires PKCE; package:oidc uses it by default for + // // the Authorization Code flow, so no extra wiring is needed. + // ...?config.extraAuthParameters, + // }, + // extraTokenParameters: config.extraTokenParameters ?? {}, + // ); + + // 4. Construct the manager — plain httpClient, no DPoP wrapping needed. + final manager = metadata != null + ? OidcUserManager( + discoveryDocument: metadata.oidcMetadata, + clientCredentials: clientAuth, + store: OidcDefaultStore(), + settings: settings, + httpClient: config.httpClient, + ) + : OidcUserManager.lazy( + discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri( + Uri.parse(issuerUri), + ), + clientCredentials: clientAuth, + store: OidcDefaultStore(), + settings: settings, + httpClient: config.httpClient, + ); + + // if (metadata != null) { + // // Use the pre-fetched discovery document to skip a network call. + // return OidcUserManager( + // discoveryDocument: metadata.oidcMetadata, + // clientCredentials: clientAuth, + // store: OidcDefaultStore(), + // settings: settings, + // httpClient: config.httpClient, + // ); + // } + + // // Lazy path: let package:oidc fetch the discovery document on init(). + // return OidcUserManager.lazy( + // discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri( + // Uri.parse(issuerUri), + // ), + // clientCredentials: clientAuth, + // store: OidcDefaultStore(), + // settings: settings, + // httpClient: config.httpClient, + // ); + + return (manager: manager, keyManager: keyManager); } static List _ensureWebIdScope(List scopes) { diff --git a/lib/src/dpop/dpop_key_manager.dart b/lib/src/dpop/dpop_key_manager.dart index 3b969c2..4210ecf 100644 --- a/lib/src/dpop/dpop_key_manager.dart +++ b/lib/src/dpop/dpop_key_manager.dart @@ -1,71 +1,152 @@ -import 'dart:convert'; +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage +library; + +// import 'dart:convert'; +// import 'dart:typed_data'; +// import 'package:crypto/crypto.dart'; import 'package:fast_rsa/fast_rsa.dart'; import 'package:logging/logging.dart'; final _log = Logger('solid_auth.DpopKeyManager'); -/// Manages the RSA key pair used for generating DPoP proofs. +/// Manages the RSA key pair used for DPoP proofs. +/// +/// ## Why this must be created BEFORE authentication +/// +/// Solid-OIDC requires DPoP key binding at the **token endpoint** level +/// (RFC 9449 §5). The client MUST send a DPoP proof JWT as the `DPoP` +/// HTTP header on the token endpoint request. The OP then: /// -/// DPoP (Demonstrating Proof-of-Possession) binds an access token to a -/// specific key pair so that the token cannot be replayed by another party. +/// 1. Validates the proof. +/// 2. Computes `jkt` = base64url(SHA-256(RFC 7638 JWK thumbprint)). +/// 3. Embeds `cnf: { jkt: "…" }` in the issued access token. /// -/// Reference: https://datatracker.ietf.org/doc/html/rfc9449 +/// A Resource Server verifying a `PATCH` / `GET` / etc. request later +/// checks that the DPoP proof was signed by the key whose thumbprint +/// matches `cnf.jkt` in the access token. /// -/// The key pair is generated once per session and cached. For long-running -/// apps you may want to rotate the key pair periodically. +/// If the token was issued WITHOUT a DPoP proof at token-request time +/// there is no `cnf` claim at all, and the RS returns: +/// +/// > "Expected object property cnf, got: [object Object]" +/// +/// **Fix**: generate the key pair here ONCE, inject it into every token +/// request via a `package:oidc` token hook, and reuse the same key pair +/// for all subsequent resource-level DPoP proofs. class DpopKeyManager { DpopKeyManager._({ required this.keyPair, required this.publicKeyJwk, + // required this.jkt, }); - /// The RSA key pair — both private and public key in PEM format. + /// RSA-2048 key pair (PEM-encoded). final KeyPair keyPair; - /// The public key as a JSON Web Key (JWK) map, included in every DPoP proof - /// header under `"jwk"`. + /// Public key as a JWK map — embedded in every DPoP proof JWT header. final Map publicKeyJwk; + /// JWK thumbprint (RFC 7638, SHA-256, base64url, no padding). + /// + /// This is what the OP stores as `cnf.jkt` inside the access token. + /// Resource Servers use this value to confirm the proof was signed by + /// the same key that was presented at token-issuance time. + // final String jkt; + + // ── Singleton ───────────────────────────────────────────────────────────── + static DpopKeyManager? _instance; - /// Generates a new RSA-2048 key pair and returns a [DpopKeyManager]. + /// Returns the cached key manager, generating a fresh pair if none exists. /// - /// The result is cached for the lifetime of the process. Call [rotate] to - /// generate a fresh pair (e.g. after a long idle period). + /// Call **before** starting the auth flow so the key is ready when the + /// token-endpoint hook fires. static Future getInstance() async { - if (_instance != null) return _instance!; - return rotate(); + return _instance ??= await _generate(); } - /// Generates a new key pair, replacing any cached instance. + /// Generates a new RSA-2048 pair and replaces the cached instance. + /// + /// Use on logout or to rotate the DPoP binding key. static Future rotate() async { - _log.fine('Generating new RSA-2048 key pair for DPoP'); - final keyPair = await RSA.generate(2048); - final jwk = await _publicKeyToJwk(keyPair.publicKey); - _instance = DpopKeyManager._(keyPair: keyPair, publicKeyJwk: jwk); - _log.fine('DPoP key pair ready (kid: ${jwk['kid']})'); - return _instance!; + _instance = null; + return getInstance(); } - /// Clears the cached key pair (e.g. on logout). + /// Clears the cached instance (call on logout). static void clear() { _instance = null; } - // ── JWK conversion ───────────────────────────────────────────────────────── - - static Future> _publicKeyToJwk( - String publicKeyPem, - ) async { - // fast_rsa can export a public key as a PKCS#1 DER and we convert to JWK. - final jwkJson = await RSA.convertPublicKeyToJWK(publicKeyPem); - final jwk = jsonDecode(jwkJson) as Map; - // Ensure the key type is set correctly for RS256. - return { - 'kty': 'RSA', - 'use': 'sig', - 'alg': 'RS256', - ...jwk, - }; + /// Generates a new key pair, replacing any cached instance. + static Future _generate() async { + // final keyPair = await RSA.generate(2048); + // final jwk = await _buildJwk(keyPair.publicKey); + // final jkt = _computeJkt(jwk); + // _log.fine('DPoP key pair ready — jkt: $jkt'); + // return DpopKeyManager._(keyPair: keyPair, publicKeyJwk: jwk, jkt: jkt); + + _log.fine('Generating RSA-2048 key pair for DPoP'); + final keyPair = await RSA.generate(2048); + // final jwk = await _publicKeyToJwk(keyPair.publicKey); + final publicKeyJwk = await RSA.convertPublicKeyToJWK(keyPair.publicKey); + + // Also adds the required `alg: "RS256"` parameter to the JWK + publicKeyJwk['alg'] = 'RS256'; + + /// av: Following jkt computation is not necessary. Keep commented + /// for now for further testing + // final jkt = _computeJkt(publicKeyJwk); + + _instance = DpopKeyManager._( + keyPair: keyPair, + publicKeyJwk: publicKeyJwk, + // jkt: jkt, + ); + // _log.fine('DPoP key pair ready (kid: ${jwk['kid']})'); + return _instance!; } + + /// RFC 7638 §3.2 — JWK thumbprint for RSA keys. + /// + /// Required members in lexicographic order: e, kty, n. + /// Result: base64url( SHA-256( UTF8( canonical JSON ) ) ), no padding. + // static String _computeJkt(Map jwk) { + // final canonical = jsonEncode({ + // 'e': jwk['e'], + // 'kty': jwk['kty'], + // 'n': jwk['n'], + // }); + // final digest = sha256.convert(utf8.encode(canonical)); + // return _base64UrlNoPad(Uint8List.fromList(digest.bytes)); + // } + + // static String _base64UrlNoPad(Uint8List bytes) => + // base64Url.encode(bytes).replaceAll('=', ''); } diff --git a/lib/src/dpop/dpop_token_generator.dart b/lib/src/dpop/dpop_token_generator.dart index ab5743d..673aba1 100644 --- a/lib/src/dpop/dpop_token_generator.dart +++ b/lib/src/dpop/dpop_token_generator.dart @@ -1,34 +1,108 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage +library; + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:fast_rsa/fast_rsa.dart'; import 'package:uuid/uuid.dart'; import 'package:logging/logging.dart'; -import 'dpop_key_manager.dart'; +import 'package:solid_auth/src/dpop/dpop_key_manager.dart'; final _log = Logger('solid_auth.DpopTokenGenerator'); const _uuid = Uuid(); -/// Generates DPoP (Demonstrating Proof-of-Possession) proof tokens. +/// Generates DPoP (Demonstrating Proof-of-Possession) proof tokens per +/// RFC 9449 and the Solid-OIDC specification. /// -/// A DPoP proof is a short-lived JWT that binds an HTTP request to the -/// key pair associated with the current session. It must be sent alongside -/// the `Authorization: DPoP ` header. +/// ## Two kinds of DPoP proof /// -/// Reference: https://datatracker.ietf.org/doc/html/rfc9449 +/// ### 1. Token-endpoint proof (call [generateForTokenEndpoint]) /// -/// ## Migration from solid_auth 0.1.x +/// Sent as the `DPoP` header on the token request to the OP. +/// The OP uses it to key-bind the issued access token by embedding +/// `cnf: { jkt: "" }` in the token payload. /// -/// The old free-standing function signature: -/// ```dart -/// String genDpopToken(endPointUrl, rsaKeyPair, publicKeyJwk, httpMethod) /// ``` -/// is preserved as the static [generate] method, but the recommended -/// new approach is to use [generateForRequest] which fetches the key pair -/// from [DpopKeyManager] automatically. +/// POST /token +/// DPoP: ← no `ath` claim here +/// Content-Type: application/x-www-form-urlencoded +/// ... +/// ``` +/// +/// ### 2. Resource-server proof (call [generateForRequest]) +/// +/// Sent alongside every protected-resource HTTP request. +/// The RS checks: +/// - `htm` matches the HTTP method. +/// - `htu` matches the request URL. +/// - `jti` has not been seen before (replay prevention). +/// - The proof is signed by the key whose thumbprint matches `cnf.jkt` +/// in the access token. +/// - `ath` = base64url(SHA-256(ASCII(access_token))). +/// +/// ``` +/// PATCH /resource +/// Authorization: DPoP +/// DPoP: ← includes `ath` claim +/// ``` +/// +/// The `cnf` error your Solid server returned means the access token was +/// issued WITHOUT step 1 — there was no DPoP proof on the token request. abstract class DpopTokenGenerator { DpopTokenGenerator._(); - // ── New API ─────────────────────────────────────────────────────────────── + // ── Token-endpoint proof ─────────────────────────────────────────────────── + + /// Generates a DPoP proof for the **token endpoint request**. + /// + /// Must be sent as the `DPoP` header when calling the OP's token endpoint. + /// Do NOT include an `ath` claim here (there is no access token yet). + /// + /// [tokenEndpointUrl] — the OP token endpoint URI (e.g. + /// `https://solidcommunity.net/token`). + static Future generateForTokenEndpoint({ + required String tokenEndpointUrl, + DpopKeyManager? keyManager, + }) async { + final km = keyManager ?? await DpopKeyManager.getInstance(); + _log.fine('Generating DPoP token-endpoint proof for: $tokenEndpointUrl'); + return generate( + httpMethod: 'POST', + endpointUrl: tokenEndpointUrl, + keyPair: km.keyPair, + publicKeyJwk: km.publicKeyJwk, + accessToken: null, // no ath on token request + ); + } /// Generates a DPoP proof for [httpMethod] on [endpointUrl], automatically /// using the managed key pair from [DpopKeyManager]. @@ -39,12 +113,14 @@ abstract class DpopTokenGenerator { required String endpointUrl, required String httpMethod, String? accessToken, + DpopKeyManager? keyManager, }) async { - final keyManager = await DpopKeyManager.getInstance(); + // final keyManager = await DpopKeyManager.getInstance(); + final km = keyManager ?? await DpopKeyManager.getInstance(); return generate( endpointUrl: endpointUrl, - keyPair: keyManager.keyPair, - publicKeyJwk: keyManager.publicKeyJwk, + keyPair: km.keyPair, + publicKeyJwk: km.publicKeyJwk, httpMethod: httpMethod, accessToken: accessToken, ); @@ -73,11 +149,27 @@ abstract class DpopTokenGenerator { }) { _log.fine('Generating DPoP proof: $httpMethod $endpointUrl'); + final String tokenId = _uuid.v4(); // Unique token ID (replay protection) + + /// 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}; + + // RFC 9449 §4.2: htu MUST NOT include query or fragment components. + final parsedUrl = Uri.parse(endpointUrl); + final htu = Uri( + scheme: parsedUrl.scheme, + host: parsedUrl.host, + port: parsedUrl.hasPort ? parsedUrl.port : null, + path: parsedUrl.path, + ).toString(); + final payload = { - 'jti': _uuid.v4(), // Unique token ID (replay protection) + 'htu': htu, 'htm': httpMethod.toUpperCase(), - 'htu': endpointUrl, - 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'jti': tokenId, + 'iat': (DateTime.now().millisecondsSinceEpoch / 1000).round(), }; // `ath` claim: base64url(sha256(ascii(access_token))) @@ -86,16 +178,17 @@ abstract class DpopTokenGenerator { payload['ath'] = _sha256Base64Url(accessToken); } + /// Create a json web token final jwt = JWT( payload, - header: { - 'typ': 'dpop+jwt', - 'alg': 'RS256', - 'jwk': publicKeyJwk, - }, + header: tokenHead, ); - return jwt.sign(RSAPrivateKey(keyPair.privateKey)); + /// Sign the JWT using private key + return jwt.sign( + RSAPrivateKey(keyPair.privateKey), + algorithm: JWTAlgorithm.RS256, + ); } // ── Internal ─────────────────────────────────────────────────────────────── @@ -103,14 +196,8 @@ abstract class DpopTokenGenerator { /// Returns the base64url-encoded SHA-256 hash of [input] (ASCII encoded). /// Used for the `ath` claim per RFC 9449 §4.2. static String _sha256Base64Url(String input) { - // dart_jsonwebtoken uses pointycastle internally; we use its hashing here. - // In a real implementation wire in a sha256 utility from pointycastle or - // crypto package. Shown here as a placeholder. - // ignore: todo - // TODO: replace with `crypto` package sha256 + base64Url encoding. - throw UnimplementedError( - 'SHA-256/base64url for ath claim — wire in the `crypto` package: ' - 'base64Url.encode(sha256.convert(ascii.encode(accessToken)).bytes)', - ); + return base64Url + .encode(sha256.convert(ascii.encode(input)).bytes) + .replaceAll('=', ''); } } From 31a479c9fb3418f1892c5aaceb35aa1c96a932c8 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Mon, 18 May 2026 22:51:24 +1000 Subject: [PATCH 09/14] change to a single output --- example/lib/screens/EditProfile.dart | 7 +-- example/lib/screens/LoginScreen.dart | 57 +++++++++++++++---------- example/lib/screens/PrivateProfile.dart | 17 +++----- example/lib/screens/PrivateScreen.dart | 7 +-- example/lib/screens/ProfileInfo.dart | 3 -- example/lib/screens/PublicProfile.dart | 2 +- lib/src/auth/solid_auth_manager.dart | 11 ++++- 7 files changed, 56 insertions(+), 48 deletions(-) diff --git a/example/lib/screens/EditProfile.dart b/example/lib/screens/EditProfile.dart index 020a529..f411bfb 100644 --- a/example/lib/screens/EditProfile.dart +++ b/example/lib/screens/EditProfile.dart @@ -47,13 +47,11 @@ import 'package:solid_auth_example/models/SolidApi.dart'; import 'package:solid_auth_example/screens/PrivateScreen.dart'; class EditProfile extends StatefulWidget { - final SolidAuthData authData; final SolidAuthManager authManager; final String webId; final Map profData; const EditProfile({ Key? key, - required this.authData, required this.authManager, required this.webId, required this.profData, @@ -137,7 +135,6 @@ class _EditProfileState extends State { context, MaterialPageRoute( builder: (context) => PrivateScreen( - authData: widget.authData, authManager: widget.authManager, )), ); @@ -163,7 +160,8 @@ class _EditProfileState extends State { ), ElevatedButton( onPressed: () async { - final authData = widget.authData; + final authData = + widget.authManager.authData!; // Get access token String accessToken = authData.accessToken; @@ -277,7 +275,6 @@ class _EditProfileState extends State { context, MaterialPageRoute( builder: (context) => PrivateScreen( - authData: widget.authData, authManager: widget.authManager, )), ); diff --git a/example/lib/screens/LoginScreen.dart b/example/lib/screens/LoginScreen.dart index 7d9d933..609fa5e 100644 --- a/example/lib/screens/LoginScreen.dart +++ b/example/lib/screens/LoginScreen.dart @@ -237,25 +237,33 @@ class LoginScreen extends StatelessWidget { // Define Solid Auth Manager final authManager = SolidAuthManager( config: SolidOidcConfig( - // clientId: 'my_solid_client', - - // // On mobile: a custom-scheme URI registered with the OS. - // // On web: the path to your redirect.html (see package:oidc docs). - // redirectUri: Uri.parse('com.example.solidapp://callback'), - - // postLogoutRedirectUri: Uri.parse('com.example.solidapp://callback'), - + /// Custom URI schemes defined depending on the platform + /// [clientId] parameter should point to a `jsonld` document + /// containing the required authentication details. + /// For example see: https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld + /// + /// redirectUris for each platform defined below should match + /// the redirect uris defined on the clientId document above + /// + /// Client ID document hosted on web. Having a separate document for a client app + /// will prevent the app from requiring to do dynamic client registration everytime + /// app logs in clientId: 'https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld', - // On mobile: a custom-scheme URI registered with the OS. - // On web: the path to your redirect.html (see package:oidc docs). + /// Use the following schemes for defining redirect uris + /// Also refer to the oidc documentation + /// at: https://bdaya-dev.github.io/oidc/oidc-getting-started/ + /// On mobile: a custom-scheme URI registered with the OS (eg: com.example.solid.auth.example://redirect) + /// On web: the path to your redirect.html (eg: https://anushkavidanage.github.io/solid_auth/example_app/redirect.html) + /// On desktop: localhost as per oidc documentation (eg: http://localhost:0/redirect) redirectUri: Uri.parse('http://localhost:0/redirect'), + /// Use the same redirect uris used above for corresponding plaform postLogoutRedirectUri: Uri.parse( 'http://localhost:0/redirect'), //Uri.parse('${appUrlScheme}://logout'), - // Solid-OIDC scopes — webid is always added automatically. + /// Solid-OIDC scopes. Webid is always added automatically scopes: SolidScopes.defaultScopes, ), ); @@ -264,18 +272,23 @@ class LoginScreen extends StatelessWidget { try { // getIssuer() + OidcUserManager.init() + loginAuthorizationCodeFlow() // are all handled internally. - final authData = - await authManager.authenticate(webIdController.text); + await authManager.authenticate(webIdController.text); - // Navigate to the profile through main screen - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PrivateScreen( - authData: authData, - authManager: authManager, - )), - ); + if (authManager.authData != null) { + // Navigate to the profile through main screen + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => PrivateScreen( + authManager: authManager, + )), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Login failed! \n Try again in few seconds'), + duration: const Duration(milliseconds: 3000), + )); + } } on SolidAuthException catch (e) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Login failed! \n ${e.message})'), diff --git a/example/lib/screens/PrivateProfile.dart b/example/lib/screens/PrivateProfile.dart index 92e8195..a8e97c2 100644 --- a/example/lib/screens/PrivateProfile.dart +++ b/example/lib/screens/PrivateProfile.dart @@ -46,11 +46,8 @@ import 'package:solid_auth_example/models/SolidApi.dart' as rest_api; import 'package:solid_auth_example/screens/ProfileInfo.dart'; class PrivateProfile extends StatefulWidget { - final SolidAuthData authData; // Authentication data final SolidAuthManager authManager; - const PrivateProfile( - {Key? key, required this.authData, required this.authManager}) - : super(key: key); + const PrivateProfile({Key? key, required this.authManager}) : super(key: key); @override State createState() => _PrivateProfileState(); @@ -160,11 +157,11 @@ class _PrivateProfileState extends State { controller: ScrollController(), padding: EdgeInsets.all(kDefaultPadding * 1.5), child: ProfileInfo( - profData: profData, - authManager: widget.authManager, - profType: 'private', - webId: webId, - authData: authData)), + profData: profData, + authManager: widget.authManager, + profType: 'private', + webId: webId, + )), ) ], ), @@ -173,7 +170,7 @@ class _PrivateProfileState extends State { @override Widget build(BuildContext context) { - SolidAuthData authData = widget.authData; + SolidAuthData authData = widget.authManager.authData!; String webId = authData.webId; // Get profile url diff --git a/example/lib/screens/PrivateScreen.dart b/example/lib/screens/PrivateScreen.dart index becedca..9472b3f 100644 --- a/example/lib/screens/PrivateScreen.dart +++ b/example/lib/screens/PrivateScreen.dart @@ -44,16 +44,13 @@ import 'package:solid_auth_example/screens/PrivateProfile.dart'; // ignore: must_be_immutable class PrivateScreen extends StatelessWidget { - SolidAuthData authData; // Authentication data SolidAuthManager authManager; - PrivateScreen({Key? key, required this.authData, required this.authManager}) - : super(key: key); + PrivateScreen({Key? key, required this.authManager}) : super(key: key); @override Widget build(BuildContext context) { // Assign loading screen - var loadingScreen = - PrivateProfile(authData: authData, authManager: authManager); + var loadingScreen = PrivateProfile(authManager: authManager); // Setup Scaffold to be responsive return Scaffold( diff --git a/example/lib/screens/ProfileInfo.dart b/example/lib/screens/ProfileInfo.dart index b372f5e..613a5b4 100644 --- a/example/lib/screens/ProfileInfo.dart +++ b/example/lib/screens/ProfileInfo.dart @@ -43,7 +43,6 @@ import 'package:solid_auth_example/screens/EditProfile.dart'; class ProfileInfo extends StatelessWidget { final Map profData; // Profile data - final SolidAuthData? authData; // Authentication related data final SolidAuthManager? authManager; final String profType; // Public or private final String? webId; // WebId of the user @@ -53,7 +52,6 @@ class ProfileInfo extends StatelessWidget { required this.profData, this.authManager, required this.profType, - this.authData, this.webId}) : super(key: key); @@ -111,7 +109,6 @@ class ProfileInfo extends StatelessWidget { context, MaterialPageRoute( builder: (context) => EditProfile( - authData: authData!, authManager: authManager!, webId: webId!, profData: profData, diff --git a/example/lib/screens/PublicProfile.dart b/example/lib/screens/PublicProfile.dart index 75670ef..f6dd89c 100644 --- a/example/lib/screens/PublicProfile.dart +++ b/example/lib/screens/PublicProfile.dart @@ -39,7 +39,7 @@ import 'package:http/http.dart' as http; // Project imports: import 'package:solid_auth_example/models/Constants.dart'; -import 'package:solid_auth_example/components/Header.dart'; +// import 'package:solid_auth_example/components/Header.dart'; import 'package:solid_auth_example/models/GetRdfData.dart'; import 'package:solid_auth_example/screens/ProfileInfo.dart'; diff --git a/lib/src/auth/solid_auth_manager.dart b/lib/src/auth/solid_auth_manager.dart index 17d1ab8..c1a71c8 100644 --- a/lib/src/auth/solid_auth_manager.dart +++ b/lib/src/auth/solid_auth_manager.dart @@ -99,6 +99,11 @@ class SolidAuthManager { final SolidOidcConfig config; final http.Client? httpClient; + /// The [SolidAuthData] instance for the auth manager + /// Stores authentication data such as access token and web id + /// after a successful authentication + SolidAuthData? authData; + OidcUserManager? _oidcManager; /// The [DpopKeyManager] created during [initForIssuer]. @@ -141,7 +146,7 @@ class SolidAuthManager { /// Authorization Code + PKCE flow. /// /// Returns a [SolidAuthData] with the tokens and extracted WebID on success. - Future authenticate( + Future authenticate( String webIdOrIssuerUri, { List? scopeOverride, }) async { @@ -149,7 +154,9 @@ class SolidAuthManager { final issuerUri = await WebIdUtils.getIssuer(webIdOrIssuerUri, httpClient: httpClient); - return login(issuerUri: issuerUri, scopeOverride: scopeOverride); + authData = await login(issuerUri: issuerUri, scopeOverride: scopeOverride); + + return authData; } /// Initialises for [issuerUri] and triggers the Authorization Code flow. From b9b3794e163d94545b16d1366e01af2e576e9bd3 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 19 May 2026 13:49:57 +1000 Subject: [PATCH 10/14] Update gitignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 99f5900..068131f 100644 --- a/.gitignore +++ b/.gitignore @@ -303,3 +303,9 @@ app.*.symbols !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages !/dev/ci/**/Gemfile.lock + +#----------------------------------------------------------------------- +# Claude related files +#----------------------------------------------------------------------- +Claude.md +.claude/* \ No newline at end of file From 79560e7a55cbd1efaa9ad92bda8f83b9d5421f7c Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 19 May 2026 14:49:52 +1000 Subject: [PATCH 11/14] add fast_rsa assets --- pubspec.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pubspec.yaml b/pubspec.yaml index 8f4ce12..ffd2198 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,3 +39,20 @@ dev_dependencies: sdk: flutter flutter_lints: ^4.0.0 mocktail: ^1.0.0 + +# Running platforms +platforms: + android: + ios: + linux: + macos: + web: + windows: + +# fast_rsa doesn't declare its own web assets in pubspec.yaml, so they +# must be declared here or they won't be present in the web build output. +flutter: + assets: + - packages/fast_rsa/web/assets/worker.js + - packages/fast_rsa/web/assets/wasm_exec.js + - packages/fast_rsa/web/assets/rsa.wasm From e3db3f647894418634611ab913367295439d2aca Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 19 May 2026 14:59:41 +1000 Subject: [PATCH 12/14] web build issue fix --- example/web/callback.html | 30 ------------------------------ example/web/index.html | 14 +++++++++++--- example/web/manifest.json | 4 ++-- 3 files changed, 13 insertions(+), 35 deletions(-) delete mode 100644 example/web/callback.html diff --git a/example/web/callback.html b/example/web/callback.html deleted file mode 100644 index b7fe4ba..0000000 --- a/example/web/callback.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - diff --git a/example/web/index.html b/example/web/index.html index 1aa025d..1d28adf 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -21,18 +21,26 @@ - + - + - example + solid_auth_example + diff --git a/example/web/manifest.json b/example/web/manifest.json index 096edf8..532bab4 100644 --- a/example/web/manifest.json +++ b/example/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "example", - "short_name": "example", + "name": "solid_auth_example", + "short_name": "solid_auth_example", "start_url": ".", "display": "standalone", "background_color": "#0175C2", From 61aa945cd788df4854a68a71f45753597fc3a7e8 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 20 May 2026 21:46:09 +1000 Subject: [PATCH 13/14] Add key restore function --- lib/src/dpop/dpop_key_manager.dart | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/src/dpop/dpop_key_manager.dart b/lib/src/dpop/dpop_key_manager.dart index 4210ecf..87bd59d 100644 --- a/lib/src/dpop/dpop_key_manager.dart +++ b/lib/src/dpop/dpop_key_manager.dart @@ -104,6 +104,26 @@ class DpopKeyManager { _instance = null; } + /// Restores the singleton from previously persisted PEM-encoded keys. + /// + /// Call this before [getInstance] on app restart to reuse the same key + /// pair that was active when the access token was issued. This ensures + /// the access token's cnf.jkt still matches the restored key pair, + /// avoiding the DPoP thumbprint mismatch the server would otherwise reject. + static Future restoreFromPem({ + required String privateKeyPem, + required String publicKeyPem, + }) async { + final publicKeyJwk = await RSA.convertPublicKeyToJWK(publicKeyPem); + publicKeyJwk['alg'] = 'RS256'; + // KeyPair constructor order: KeyPair(publicKey, privateKey) + _instance = DpopKeyManager._( + keyPair: KeyPair(publicKeyPem, privateKeyPem), + publicKeyJwk: publicKeyJwk, + ); + return _instance!; + } + /// Generates a new key pair, replacing any cached instance. static Future _generate() async { // final keyPair = await RSA.generate(2048); From 98d7e39ec8188773ae1521d67a84c0cd14609989 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Thu, 21 May 2026 16:46:20 +1000 Subject: [PATCH 14/14] update readme and changelog --- CHANGELOG.md | 6 ++ README.md | 205 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 134 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a62a5d0..5ec2fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,14 @@ description of the update. Updates in the 0.1.n series are heading toward a 0.2 release. The `[version timestamp user]` string is utilised by the flutter version_widget package. +## 0.3 + ++ Implementing Authorization Code + PKCE, DPoP key binding (RFC 9449), and WebID-based issuer discovery using OpenID-certified [`package:oidc`](https://pub.dev/packages/oidc) [0.2.0 20260521 anushkavidanage] + ## 0.2 Stability ++ Update Try Another WebID workflow [0.1.30 20260520 tonypioneer] ++ Update jose dependency [0.1.29 20260415 jesscmoore] + Review and cleanup for publication [0.1.28 20250925 gjw] + Remove jwt, update openid, use encrypt_plus [0.1.28 20250923 anushkavidanage] + Export openid_client [0.1.28 20250917 anushkavidanage] diff --git a/README.md b/README.md index ba17f74..109f2b3 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,166 @@ -# solid_auth (restructured) +# solid_auth -Solid-OIDC authentication for Flutter, now built on the -[OpenID-certified `oidc` package](https://pub.dev/packages/oidc). +[![Flutter](https://img.shields.io/badge/Flutter-%2302569B.svg?style=for-the-badge&logo=Flutter&logoColor=white)](https://flutter.dev) +[![Dart](https://img.shields.io/badge/dart-%230175C2.svg?style=for-the-badge&logo=dart&logoColor=white)](https://dart.dev) ---- +[![GitHub License](https://img.shields.io/github/license/anusii/solid_auth)](https://raw.githubusercontent.com/anusii/solid_auth/dev/LICENSE) +[![GitHub Version](https://img.shields.io/badge/dynamic/yaml?url=https://raw.githubusercontent.com/anusii/solid_auth/master/pubspec.yaml&query=$.version&label=version&logo=github)](https://github.com/anusii/solid_auth/blob/dev/CHANGELOG.md) +[![Pub Version](https://img.shields.io/pub/v/solid_auth?label=pub.dev&labelColor=333940&logo=flutter)](https://pub.dev/packages/solid_auth) +[![GitHub Last Updated](https://img.shields.io/github/last-commit/anusii/solid_auth?label=last%20updated)](https://github.com/anusii/solid_auth/commits/dev/) +[![GitHub Commit Activity (main)](https://img.shields.io/github/commit-activity/w/anusii/solid_auth/dev)](https://github.com/anusii/solid_auth/commits/dev/) +[![GitHub Issues](https://img.shields.io/github/issues/anusii/solid_auth)](https://github.com/anusii/solid_auth/issues) -## Architecture overview +Solid-OIDC authentication for Flutter apps. Handles Authorization Code + PKCE, DPoP key binding (RFC 9449), and WebID-based issuer discovery. This package is built on the OpenID-certified [`package:oidc`](https://pub.dev/packages/oidc). -``` -solid_auth (public API) -│ -├── SolidAuthManager ← main facade (replaces authenticate()) -│ ├── loginFromWebId() ← resolves issuer, then logs in -│ ├── login() ← direct login given issuer URI -│ ├── currentAuthData ← typed SolidAuthData (not a raw Map) -│ ├── authChanges ← Stream (like Firebase Auth) -│ └── logout() / dispose() -│ -├── SolidOidcManagerFactory ← wires SolidOidcConfig → OidcUserManager -│ └── create() -│ -├── DpopTokenGenerator ← DPoP proof JWT generation (unchanged logic) -│ ├── generateForRequest() ← new: auto-fetches key from DpopKeyManager -│ └── generate() ← legacy-compatible static method -│ -├── DpopKeyManager ← RSA key-pair lifecycle -├── ProfileFetcher ← replaces fetchProfileData() -│ └── fetchProfile() → SolidProfile -│ -└── WebIdUtils ← replaces getIssuer() - ├── getIssuer() - └── getProviderMetadata() → SolidProviderMetadata -``` +--- -### Dependency map +## Features -``` -solid_auth - └── package:oidc (OidcUserManager, OidcUserManagerSettings, etc.) - └── oidc_core (OidcProviderMetadata, OidcToken, etc.) - └── oidc_default_store (secure token persistence) - └── dart_jsonwebtoken (DPoP JWT signing — kept) - └── fast_rsa (RSA key generation — kept) -``` - -The entire forked `openid_client` code is **removed**. All OIDC discovery, -PKCE, token exchange and refresh is delegated to `package:oidc`. +- **Solid-OIDC login** via Authorization Code + PKCE on all platforms (Android, iOS, Web, Windows, macOS, Linux) +- **DPoP key binding** - generates and manages RSA-2048 key pairs; injects DPoP proof headers automatically at every token request +- **WebID issuer discovery** - resolves an OIDC issuer from any WebID profile URL +- **Typed auth result** (`SolidAuthData`) - replaces the old raw `Map` --- -## Migration guide — 0.1.x → 0.2.x +## Installation + +```yaml +dependencies: + solid_auth: ^0.2.0 +``` -| Old (0.1.x) | New (0.2.x) | -|------------------------------------------------------|----------------------------------------------------| -| `String issuer = await getIssuer(webId)` | `WebIdUtils.getIssuer(webId)` (same signature) | -| `var data = await authenticate(issuerUri, scopes)` | `SolidAuthManager.loginFromWebId(webId)` returns `SolidAuthData` | -| `data['accessToken']` | `authData.accessToken` | -| `data['idToken']` | `authData.idToken` | -| `genDpopToken(url, keyPair, jwk, method)` | `DpopTokenGenerator.generate(...)` (same params) | -| `fetchProfileData(webId)` | `ProfileFetcher().fetchProfile(webId)` | + --- -## Quick start +## Quick Start ```dart import 'package:solid_auth/solid_auth.dart'; -// 1. Create the manager (once, at app level) +// 1. Create the manager once (e.g. at widget level or in a provider). final auth = SolidAuthManager( config: SolidOidcConfig( - clientId: 'my_client_id', - redirectUri: Uri.parse('com.example.app://callback'), - scopes: SolidScopes.defaultScopes, // includes webid automatically + clientId: 'https://your-domain/client-profile.jsonld', + redirectUri: Uri.parse('https://your-domain/redirect.html'), + postLogoutRedirectUri: Uri.parse('https://your-domain/redirect.html'), + scopes: SolidScopes.defaultScopes, // includes `webid` automatically ), ); -// 2. Login — resolves issuer from WebID, then runs Authorization Code + PKCE -final authData = await auth.loginFromWebId( - 'https://charlieb.solidcommunity.net/profile/card#me', +// 2. Login — resolves issuer from WebID, then runs Authorization Code + PKCE. +final authData = await auth.authenticate( + 'https://pods.solidcommunity.au/alice-barnes/profile/card#me', ); -print(authData.webId); // https://charlieb.solidcommunity.net/profile/card#me +print(authData.webId); // https://pods.solidcommunity.au/alice-barnes/profile/card#me print(authData.accessToken); -// 3. Generate a DPoP proof for a resource request +// 3. Generate a DPoP proof for a protected resource request. final dpop = await DpopTokenGenerator.generateForRequest( - endpointUrl: 'https://charlieb.solidcommunity.net/private/notes.ttl', + endpointUrl: 'https://pods.solidcommunity.au/alice-barnes/notepod/data/notes.ttl', httpMethod: 'GET', accessToken: authData.accessToken, + keyManager: auth.keyManager, // must be the same key bound to the access token ); // Use in HTTP headers: -// 'Authorization': 'DPoP ${authData.accessToken}' -// 'DPoP': dpop - -// 4. Fetch public profile -final profile = await ProfileFetcher().fetchProfile(authData.webId); -print(profile.name); -print(profile.storage); +// 'Authorization': 'DPoP ${authData.accessToken}' +// 'DPoP': dpop -// 5. Logout +// 4. Logout. await auth.logout(); ``` + +`tryRestoreSession()` returns `null` if no session exists, if the refresh token has expired, or if any storage error occurs (in which case the stored session is cleared so the next login starts clean). + +Calling `logout()` or `forgetUser()` always clears the stored session. + +--- + +## DPoP for Resource Requests + +Every request to a Solid server protected resource needs both an `Authorization` header and a fresh DPoP proof. The proof must be signed by the **same** RSA key that was active during login (the access token's `cnf.jkt` claim is bound to it): + +```dart +Future getPrivateResource( + SolidAuthManager auth, + String resourceUrl, +) async { + final authData = auth.currentAuthData!; + + final dpop = await DpopTokenGenerator.generateForRequest( + endpointUrl: resourceUrl, + httpMethod: 'GET', + accessToken: authData.accessToken, + keyManager: auth.keyManager, + ); + + return http.get( + Uri.parse(resourceUrl), + headers: { + 'Authorization': 'DPoP ${authData.accessToken}', + 'DPoP': dpop, + }, + ); +} +``` + +> **Important:** Always pass `keyManager: auth.keyManager`. The default (`DpopKeyManager.getInstance()`) returns the current singleton, which may be a freshly-generated key after an app restart, producing a thumbprint that the server will reject. + +--- + +## Platform Setup + +`redirectUri` and `postLogoutRedirectUri` must be registered in your [client ID document](https://solid.github.io/solid-oidc/#clientids-document) (`client-profile.jsonld`) and match the correct format for each platform: + +| Platform | URI format | Notes | +|---|---|---| +| Web | `https://your-domain/redirect.html` | Must be same origin as the app - `oidc` uses `BroadcastChannel` (same-origin only) | +| Android / iOS | `com.example.app://redirect` | Custom URI scheme registered with the OS | +| Windows / Linux / macOS | `http://localhost:4400/redirect` | **Fixed port required** - see below | + +### Desktop: use a fixed port + +`oidc_desktop` binds a loopback HTTP server to the port in your `redirectUri`. If you use port `0`, the OS assigns a random port that is never registered in the client document, causing the Solid server to reject logout with `post_logout_redirect_uri not registered`. Use a fixed port (e.g. `4400`) in both the app and the client document. + +Both `redirect_uris` and `post_logout_redirect_uris` in the client ID document must list every URI used across platforms: + +```json +{ + "redirect_uris": [ + "https://your-domain/redirect.html", + "http://localhost:4400/redirect" + ], + "post_logout_redirect_uris": [ + "https://your-domain/redirect.html", + "http://localhost:4400/redirect" + ] +} +``` + +For Android, iOS, and other platform-specific setup steps (manifest entries, URL schemes, etc.), follow the [`package:oidc` Getting Started guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/). + --- -## Platform setup +## Migration Guide - 0.1.x → 0.2.x -Platform-specific setup (Android `build.gradle`, iOS `Info.plist`, -web `redirect.html`, etc.) follows `package:oidc` requirements exactly. -See the [oidc Getting Started guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/). +> [!IMPORTANT] +> Upgrading from `0.1.x` to `0.2.x` is a **breaking change**. The underlying architecture has been re-engineered. The forked `openid_client` is replaced by the OpenID-certified `package:oidc`, and the top-level `authenticate()` function is replaced by `SolidAuthManager`. Please review the full README and use the table below to update your call sites. -The old `callback.html` for web should be replaced by the -[`redirect.html`](https://github.com/Bdaya-Dev/oidc/blob/main/packages/oidc/example/web/redirect.html) -from `package:oidc`. +| Old (0.1.x) | New (0.2.x) | +|---|---| +| `String issuer = await getIssuer(webId)` | `WebIdUtils.getIssuer(webId)` (same signature) | +| `var data = await authenticate(issuerUri, scopes)` | `await SolidAuthManager.authenticate(webIdOrIssuer)` - returns `SolidAuthData` | +| `data['accessToken']` | `authData.accessToken` | +| `data['idToken']` | `authData.idToken` | +| `genDpopToken(url, keyPair, jwk, method)` | `DpopTokenGenerator.generateForRequest(endpointUrl:, httpMethod:, accessToken:, keyManager: auth.keyManager)` | +| `fetchProfileData(webId)` | Removed - use `http` + parse the Turtle response directly |