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 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 0c113de..109f2b3 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,166 @@ - +[![GitHub License](https://img.shields.io/github/license/anusii/solid_auth)](https://raw.githubusercontent.com/anusii/solid_auth/dev/LICENSE) +[![GitHub Version](https://img.shields.io/badge/dynamic/yaml?url=https://raw.githubusercontent.com/anusii/solid_auth/master/pubspec.yaml&query=$.version&label=version&logo=github)](https://github.com/anusii/solid_auth/blob/dev/CHANGELOG.md) +[![Pub Version](https://img.shields.io/pub/v/solid_auth?label=pub.dev&labelColor=333940&logo=flutter)](https://pub.dev/packages/solid_auth) +[![GitHub Last Updated](https://img.shields.io/github/last-commit/anusii/solid_auth?label=last%20updated)](https://github.com/anusii/solid_auth/commits/dev/) +[![GitHub Commit Activity (main)](https://img.shields.io/github/commit-activity/w/anusii/solid_auth/dev)](https://github.com/anusii/solid_auth/commits/dev/) +[![GitHub Issues](https://img.shields.io/github/issues/anusii/solid_auth)](https://github.com/anusii/solid_auth/issues) -# Solid Auth +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 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. +## Features -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. +- **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` -## 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 +## Installation - + -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 +## Quick Start ```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'; +// 1. Create the manager once (e.g. at widget level or in a provider). +final auth = SolidAuthManager( + config: SolidOidcConfig( + 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.authenticate( + 'https://pods.solidcommunity.au/alice-barnes/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 protected resource request. +final dpop = await DpopTokenGenerator.generateForRequest( + 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. Logout. +await auth.logout(); +``` -// Get issuer URI -String _issuerUri = await getIssuer(_myWebId); -// Define scopes. Also possible scopes -> webid, email, api -final List _scopes = [ - 'openid', - 'profile', - 'offline_access', -]; +`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). -// Authentication process for the POD issuer -var authData = await authenticate(Uri.parse(_issuerUri), _scopes); +Calling `logout()` or `forgetUser()` always clears the stored session. -// Decode access token to recheck the WebID -String accessToken = authData['accessToken']; -Map decodedToken = JwtDecoder.decode(accessToken); -String webId = decodedToken['webid']; +--- -``` +## DPoP for Resource Requests -### Accessing Public Data Example +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 -import 'package:solid_auth/solid_auth.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, + }, + ); +} +``` -// Example WebID -String _myWebId = 'https://charlieb.solidcommunity.net/profile/card#me'; +> **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. -// Get issuer URI -Future profilePage = await fetchProfileData(_myWebId); +--- -``` +## Platform Setup -### Generating DPoP Token Example +`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: -```dart -import 'package:solid_auth/solid_auth.dart'; +| 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 | -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) +### Desktop: use a fixed port -// Generate DPoP token -String dPopToken = genDpopToken(endPointUrl, rsaKeyPair, publicKeyJwk, httpMethod); +`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: -## 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 - - - - - - - - - - - +```json +{ + "redirect_uris": [ + "https://your-domain/redirect.html", + "http://localhost:4400/redirect" + ], + "post_logout_redirect_uris": [ + "https://your-domain/redirect.html", + "http://localhost:4400/redirect" + ] +} ``` + +For Android, iOS, and other platform-specific setup steps (manifest entries, URL schemes, etc.), follow the [`package:oidc` Getting Started guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/). + +--- + +## Migration Guide - 0.1.x → 0.2.x + +> [!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. + +| 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 | 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 index 9f6b1d3..e079ad4 100644 --- a/example/lib/components/Header.dart +++ b/example/lib/components/Header.dart @@ -48,11 +48,11 @@ import 'package:solid_auth_example/screens/LoginScreen.dart'; // ignore: must_be_immutable class Header extends StatelessWidget { var mainDrawer; - String logoutUrl; + SolidAuthManager authManager; Header({ Key? key, required this.mainDrawer, - required this.logoutUrl, + required this.authManager, }) : super(key: key); @override @@ -63,14 +63,15 @@ class Header extends StatelessWidget { padding: const EdgeInsets.all(kDefaultPadding / 1.5), child: Row( children: [ - if (Responsive.isMobile(context) & (logoutUrl != 'none')) + 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 (logoutUrl != 'none') SizedBox(width: kDefaultPadding / 4), - (logoutUrl != 'none') + if (authManager.isAuthenticated) + SizedBox(width: kDefaultPadding / 4), + (authManager.isAuthenticated) ? TextButton.icon( icon: Icon( Icons.logout, @@ -85,7 +86,7 @@ class Header extends StatelessWidget { ), ), onPressed: () { - logout(logoutUrl); + authManager.logout(); Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => LoginScreen()), diff --git a/example/lib/main.dart b/example/lib/main.dart index de042c1..12340ed 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,8 +1,6 @@ /// SolidPod library to support privacy first data store on Solid Servers /// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU +/// Copyright (C) 2026, Software Innovation Institute ANU /// /// Licensed under the MIT License (the "License"). /// @@ -26,7 +24,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. /// -/// Authors: AUTHORS +/// Authors: Anushka Vidanage // Add the library directive as we have doc entries above. We publish the above // meta doc lines in the docs. diff --git a/example/lib/models/SolidApi.dart b/example/lib/models/SolidApi.dart index 5f8cfe8..ecb8281 100644 --- a/example/lib/models/SolidApi.dart +++ b/example/lib/models/SolidApi.dart @@ -38,15 +38,22 @@ 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, String accessToken, String dPopToken) async { + 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 $accessToken', + 'Authorization': 'DPoP ${authData.accessToken}', 'Connection': 'keep-alive', 'DPoP': '$dPopToken', }, diff --git a/example/lib/screens/EditProfile.dart b/example/lib/screens/EditProfile.dart index e38cdcc..f411bfb 100644 --- a/example/lib/screens/EditProfile.dart +++ b/example/lib/screens/EditProfile.dart @@ -47,12 +47,12 @@ import 'package:solid_auth_example/models/SolidApi.dart'; import 'package:solid_auth_example/screens/PrivateScreen.dart'; class EditProfile extends StatefulWidget { - final Map 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); @@ -81,14 +81,12 @@ class _EditProfileState extends State { @override Widget build(BuildContext context) { - String logoutUrl = widget.authData['logoutUrl']; - return Scaffold( key: _scaffoldKey, body: SafeArea( child: Column( children: [ - Header(mainDrawer: _scaffoldKey, logoutUrl: logoutUrl), + Header(mainDrawer: _scaffoldKey, authManager: widget.authManager), Divider(thickness: 1), Expanded( child: SingleChildScrollView( @@ -137,8 +135,7 @@ class _EditProfileState extends State { context, MaterialPageRoute( builder: (context) => PrivateScreen( - authData: widget.authData, - webId: widget.webId, + authManager: widget.authManager, )), ); }, @@ -163,25 +160,22 @@ class _EditProfileState extends State { ), ElevatedButton( onPressed: () async { - var rsaInfo = widget.authData['rsaInfo']; + final authData = + widget.authManager.authData!; // 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']; + String accessToken = authData.accessToken; // Get profile URI String profCardUrl = widget.webId.replaceAll('#me', ''); - // Generate DPoP token - String dPopToken = genDpopToken(profCardUrl, - rsaKeyPair, publicKeyJwk, 'PATCH'); + String dPopToken = await DpopTokenGenerator + .generateForRequest( + endpointUrl: profCardUrl, + httpMethod: 'PATCH', + accessToken: accessToken, + ); List attrList = [ 'name', @@ -281,8 +275,7 @@ class _EditProfileState extends State { context, MaterialPageRoute( builder: (context) => PrivateScreen( - authData: widget.authData, - webId: widget.webId, + authManager: widget.authManager, )), ); }, diff --git a/example/lib/screens/LoginScreen.dart b/example/lib/screens/LoginScreen.dart index 6a3408d..609fa5e 100644 --- a/example/lib/screens/LoginScreen.dart +++ b/example/lib/screens/LoginScreen.dart @@ -1,8 +1,6 @@ /// SolidPod library to support privacy first data store on Solid Servers /// -// Time-stamp: -/// -/// Copyright (C) 2025, Software Innovation Institute ANU +/// Copyright (C) 2026, Software Innovation Institute ANU /// /// Licensed under the MIT License (the "License"). /// @@ -26,7 +24,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. /// -/// Authors: AUTHORS +/// Authors: Anushka Vidanage // Add the library directive as we have doc entries above. We publish the above // meta doc lines in the docs. @@ -36,7 +34,6 @@ 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'; @@ -212,7 +209,8 @@ class LoginScreen extends StatelessWidget { ), ), onPressed: () async => launchIssuerReg( - (await getIssuer(_webIdTextController.text)).toString()), + (await WebIdUtils.getIssuer(_webIdTextController.text)) + .toString()), child: Text( 'GET A POD', style: TextStyle( @@ -236,44 +234,66 @@ class LoginScreen extends StatelessWidget { ), ), onPressed: () async { - // Get issuer URI - String _issuerUri = await getIssuer(_webIdTextController.text); + // Define Solid Auth Manager + final authManager = SolidAuthManager( + config: SolidOidcConfig( + /// Custom URI schemes defined depending on the platform + /// [clientId] parameter should point to a `jsonld` document + /// containing the required authentication details. + /// For example see: https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld + /// + /// redirectUris for each platform defined below should match + /// the redirect uris defined on the clientId document above + /// + /// Client ID document hosted on web. Having a separate document for a client app + /// will prevent the app from requiring to do dynamic client registration everytime + /// app logs in + clientId: + 'https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld', + + /// Use the following schemes for defining redirect uris + /// Also refer to the oidc documentation + /// at: https://bdaya-dev.github.io/oidc/oidc-getting-started/ + /// On mobile: a custom-scheme URI registered with the OS (eg: com.example.solid.auth.example://redirect) + /// On web: the path to your redirect.html (eg: https://anushkavidanage.github.io/solid_auth/example_app/redirect.html) + /// On desktop: localhost as per oidc documentation (eg: http://localhost:0/redirect) + redirectUri: Uri.parse('http://localhost:0/redirect'), - // Define scopes. Also possible scopes -> webid, email, api - final List _scopes = [ - 'openid', - 'profile', - 'offline_access', - 'webid', - ]; + /// Use the same redirect uris used above for corresponding plaform + postLogoutRedirectUri: Uri.parse( + 'http://localhost:0/redirect'), //Uri.parse('${appUrlScheme}://logout'), + + /// Solid-OIDC scopes. Webid is always added automatically + scopes: SolidScopes.defaultScopes, + ), + ); // Authentication process for the POD issuer - var authData = - await authenticate(Uri.parse(_issuerUri), _scopes, context); + try { + // getIssuer() + OidcUserManager.init() + loginAuthorizationCodeFlow() + // are all handled internally. + await authManager.authenticate(webIdController.text); - if (authData.containsKey('error')) { + 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: const Text('You cancelled the login!'), + content: Text('Login failed! \n ${e.message})'), 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( diff --git a/example/lib/screens/PrivateProfile.dart b/example/lib/screens/PrivateProfile.dart index 61f7813..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 Map authData; // Authentication data - final String webId; // User WebId - - const PrivateProfile({Key? key, required this.authData, required this.webId}) - : super(key: key); + final SolidAuthManager authManager; + const PrivateProfile({Key? key, required this.authManager}) : super(key: key); @override State createState() => _PrivateProfileState(); @@ -109,8 +106,7 @@ class _PrivateProfileState extends State { ); } - Widget _loadedScreen( - Object profInfo, String webId, String logoutUrl, Map authData) { + Widget _loadedScreen(Object profInfo, String webId, SolidAuthData authData) { // Read profile info from the turtle file PodProfile podProfile = PodProfile(profInfo.toString()); @@ -154,17 +150,18 @@ class _PrivateProfileState extends State { color: Colors.white, child: Column( children: [ - Header(mainDrawer: _scaffoldKey, logoutUrl: logoutUrl), + Header(mainDrawer: _scaffoldKey, authManager: widget.authManager), Divider(thickness: 1), Expanded( child: SingleChildScrollView( controller: ScrollController(), padding: EdgeInsets.all(kDefaultPadding * 1.5), child: ProfileInfo( - profData: profData, - profType: 'private', - webId: webId, - authData: authData)), + profData: profData, + authManager: widget.authManager, + profType: 'private', + webId: webId, + )), ) ], ), @@ -173,41 +170,21 @@ class _PrivateProfileState extends State { @override Widget build(BuildContext context) { - Map authData = widget.authData; - String webId = widget.webId; - String logoutUrl = authData['logoutUrl']; - - var rsaInfo = authData['rsaInfo']; - var rsaKeyPair = rsaInfo['rsa']; - var publicKeyJwk = rsaInfo['pubKeyJwk']; - - String accessToken = authData['accessToken']; - //Map decodedToken = JwtDecoder.decode(accessToken); + SolidAuthData authData = widget.authManager.authData!; + String webId = authData.webId; - // Get profile + // Get profile url 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), + future: rest_api.fetchPrvProfile(profCardUrl, authData), builder: (context, snapshot) { Widget returnVal; - if (snapshot.connectionState == ConnectionState.done) { - returnVal = - _loadedScreen(snapshot.data!, webId, logoutUrl, authData); + if (snapshot.hasData) { + returnVal = _loadedScreen(snapshot.data!, webId, authData); } else { returnVal = _loadingScreen(); } diff --git a/example/lib/screens/PrivateScreen.dart b/example/lib/screens/PrivateScreen.dart index e71909f..9472b3f 100644 --- a/example/lib/screens/PrivateScreen.dart +++ b/example/lib/screens/PrivateScreen.dart @@ -35,6 +35,7 @@ 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: @@ -43,15 +44,13 @@ 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); + SolidAuthManager authManager; + PrivateScreen({Key? key, required this.authManager}) : super(key: key); @override Widget build(BuildContext context) { // Assign loading screen - var loadingScreen = PrivateProfile(authData: authData, webId: webId); + 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 ff9c712..613a5b4 100644 --- a/example/lib/screens/ProfileInfo.dart +++ b/example/lib/screens/ProfileInfo.dart @@ -35,6 +35,7 @@ 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'; @@ -42,15 +43,15 @@ import 'package:solid_auth_example/screens/EditProfile.dart'; class ProfileInfo extends StatelessWidget { final Map profData; // Profile data - final Map? 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); @@ -108,7 +109,7 @@ 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 1b91de4..f6dd89c 100644 --- a/example/lib/screens/PublicProfile.dart +++ b/example/lib/screens/PublicProfile.dart @@ -35,13 +35,11 @@ library; // Flutter imports: import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; -//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/components/Header.dart'; import 'package:solid_auth_example/models/GetRdfData.dart'; import 'package:solid_auth_example/screens/ProfileInfo.dart'; @@ -57,6 +55,26 @@ class PublicProfile extends StatefulWidget { 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( @@ -146,7 +164,7 @@ class _PublicProfileState extends State { color: Colors.white, child: Column( children: [ - Header(mainDrawer: _scaffoldKey, logoutUrl: 'none'), + //Header(mainDrawer: _scaffoldKey, authData: ), Divider(thickness: 1), Expanded( child: SingleChildScrollView( @@ -166,7 +184,7 @@ class _PublicProfileState extends State { key: _scaffoldKey, body: SafeArea( child: FutureBuilder( - future: fetchProfileData( + future: _fetchProfileData( webId), // Get profile data (.ttl file) from the webId builder: (context, snapshot) { Widget returnVal; 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", 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..0faf197 100644 --- a/lib/solid_auth.dart +++ b/lib/solid_auth.dart @@ -1,6 +1,6 @@ /// Support for flutter apps authenticating to a Solid server. /// -/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// Copyright (C) 2026, Software Innovation Institute, ANU. /// /// Licensed under the MIT License (the "License"). /// @@ -26,7 +26,29 @@ /// /// Authors: Anushka Vidanage -library; +/// 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: +/// +/// ```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 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 +export 'src/dpop/dpop_token_generator.dart'; +export 'src/dpop/dpop_key_manager.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..c1a71c8 --- /dev/null +++ b/lib/src/auth/solid_auth_manager.dart @@ -0,0 +1,384 @@ +/// 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 '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. +/// +/// 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 +/// +/// ```dart +/// final auth = SolidAuthManager( +/// config: SolidOidcConfig( +/// clientId: 'my_client_id', +/// redirectUri: Uri.parse('com.example.app://callback'), +/// ), +/// ); +/// +/// final data = await auth.loginFromWebId( +/// '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 +/// ); +/// ``` +/// +/// ## 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; + + /// 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]. + /// + /// **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 { + 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 [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 authenticate( + String webIdOrIssuerUri, { + List? scopeOverride, + }) async { + _log.info('Starting Solid-OIDC login for: $webIdOrIssuerUri'); + + final issuerUri = + await WebIdUtils.getIssuer(webIdOrIssuerUri, httpClient: httpClient); + authData = await login(issuerUri: issuerUri, scopeOverride: scopeOverride); + + return authData; + } + + /// 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, + scopeOverride: scopeOverride, + ); + + // 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'); + final user = await _oidcManager!.loginAuthorizationCodeFlow(); + + if (user == null) { + throw const SolidAuthTokenException('Login cancelled or failed.'); + } + + return _mapUserToAuthData(user, issuerUri); + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + /// 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 — user is already logged in. + /// } + /// ``` + 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. + 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; + _keyManager = 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() ?? '', + ); + } + + /// 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) { + final token = user.token; + final claims = user.aggregatedClaims; + + final accessToken = token.accessToken; + final idToken = token.idToken ?? ''; + final refreshToken = token.refreshToken; + final webId = _extractWebId(claims) ?? user.uid ?? ''; + + // Derive expiry: prefer explicit expiresAt, fall back to now + expires_in. + final expiresAt = DateTime.now().add(token.expiresIn!); + + 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..6a71c6f --- /dev/null +++ b/lib/src/auth/solid_oidc_manager_factory.dart @@ -0,0 +1,241 @@ +/// 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 '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'); + +/// 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 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); + + // 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: config.extraAuthParameters ?? {}, + extraTokenParameters: config.extraTokenParameters ?? {}, + hooks: OidcUserManagerHooks( + token: dpopTokenHook, + ), + ); + + 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) { + 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..87bd59d --- /dev/null +++ b/lib/src/dpop/dpop_key_manager.dart @@ -0,0 +1,172 @@ +/// 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 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; + } + + /// 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); + // 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 new file mode 100644 index 0000000..673aba1 --- /dev/null +++ b/lib/src/dpop/dpop_token_generator.dart @@ -0,0 +1,203 @@ +/// 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 '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 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 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]. + /// + /// 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, + DpopKeyManager? keyManager, + }) async { + // final keyManager = await DpopKeyManager.getInstance(); + final km = keyManager ?? await DpopKeyManager.getInstance(); + return generate( + endpointUrl: endpointUrl, + keyPair: km.keyPair, + publicKeyJwk: km.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 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 = { + 'htu': htu, + 'htm': httpMethod.toUpperCase(), + 'jti': tokenId, + 'iat': (DateTime.now().millisecondsSinceEpoch / 1000).round(), + }; + + // `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); + } + + /// Create a json web token + final jwt = JWT( + payload, + header: tokenHead, + ); + + /// Sign the JWT using private key + return jwt.sign( + RSAPrivateKey(keyPair.privateKey), + algorithm: JWTAlgorithm.RS256, + ); + } + + // ── 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) { + return base64Url + .encode(sha256.convert(ascii.encode(input)).bytes) + .replaceAll('=', ''); + } +} diff --git a/lib/src/models/solid_auth_data.dart b/lib/src/models/solid_auth_data.dart new file mode 100644 index 0000000..8ace21a --- /dev/null +++ b/lib/src/models/solid_auth_data.dart @@ -0,0 +1,78 @@ +/// 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 +/// `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..fd4a06f --- /dev/null +++ b/lib/src/models/solid_provider_metadata.dart @@ -0,0 +1,82 @@ +/// 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 +/// 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/utils/solid_scopes.dart b/lib/src/utils/solid_scopes.dart new file mode 100644 index 0000000..e51dea4 --- /dev/null +++ b/lib/src/utils/solid_scopes.dart @@ -0,0 +1,80 @@ +/// 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 +/// 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..6e1fe9d --- /dev/null +++ b/lib/src/utils/webid_utils.dart @@ -0,0 +1,188 @@ +/// 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'; + +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 6619c39..ffd2198 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,35 +1,56 @@ 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 + # 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 + 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 + pointycastle: ^4.0.0 + + # HTTP http: ^1.3.0 - jose: ^0.3.5 + + # Utilities logging: ^1.3.0 - openidconnect_web: ^1.0.26 - pointycastle: ^4.0.0 - url_launcher: ^6.3.1 uuid: ^4.5.1 - web: ^1.1.1 dev_dependencies: - flutter_lints: ^5.0.0 - jwt_decoder: ^2.0.1 - # Keep dependency checker quiet. - solid_auth_example: - path: example + flutter_test: + 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 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