From 2e4a1e32de3db872af820d0a79f8586bc60b11cf Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 24 Sep 2025 13:25:45 +1000 Subject: [PATCH 01/10] initial changes --- lib/src/openid/openid_browser.dart | 100 +++++++++++++++++++++++++++++ lib/src/openid/openid_common.dart | 92 ++++++++++++++++++++++++++ lib/src/openid/openid_io.dart | 0 pubspec.yaml | 9 +-- 4 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 lib/src/openid/openid_browser.dart create mode 100644 lib/src/openid/openid_common.dart create mode 100644 lib/src/openid/openid_io.dart diff --git a/lib/src/openid/openid_browser.dart b/lib/src/openid/openid_browser.dart new file mode 100644 index 0000000..0440d19 --- /dev/null +++ b/lib/src/openid/openid_browser.dart @@ -0,0 +1,100 @@ +/// Openid Client Browser management. Extention from the library `openid_client`. +/// +/// 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:openid_client/openid_client_browser.dart'; +import 'package:web/web.dart'; + +class AuthenticatorCustom extends Authenticator { + + AuthenticatorCustom._(this.flow) : super.credential = _credentialFromUri(flow); + + // With PKCE flow + AuthenticatorCustom( + 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; + } + +} \ No newline at end of file diff --git a/lib/src/openid/openid_common.dart b/lib/src/openid/openid_common.dart new file mode 100644 index 0000000..59872aa --- /dev/null +++ b/lib/src/openid/openid_common.dart @@ -0,0 +1,92 @@ +/// Openid functions. Extention from the library `openid_client`. +/// +/// 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:convert'; + +import 'package:openid_client/openid_client.dart'; + +class CredentialCustom extends Credential { + CredentialCustom.fromJson(super.json) : super.fromJson(); + + Future getTokenResponseCustom({ + bool forceRefresh = false, + String dPoPToken = '', + }) async { + if (!forceRefresh && + response.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'; // TODO: make this selection more explicit + + ///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; + } +} diff --git a/lib/src/openid/openid_io.dart b/lib/src/openid/openid_io.dart new file mode 100644 index 0000000..e69de29 diff --git a/pubspec.yaml b/pubspec.yaml index 3e01727..5e98eb1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,10 +14,11 @@ dependencies: dart_jsonwebtoken: ^3.2.0 fast_rsa: ^3.8.1 http: ^1.3.0 - openid_client: - git: - url: https://github.com/anusii/openid_client - ref: av/custom_edits_for_solid_auth + openid_client: ^0.4.9+1 + # openid_client: + # git: + # url: https://github.com/anusii/openid_client + # ref: av/custom_edits_for_solid_auth openidconnect_web: ^1.0.26 url_launcher: ^6.3.1 uuid: ^4.5.1 From 3b2b483cac4f8403bd2aff02a9673e592f97ca45 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 24 Sep 2025 13:26:15 +1000 Subject: [PATCH 02/10] initial changes --- lib/solid_auth_client.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/solid_auth_client.dart b/lib/solid_auth_client.dart index 23712ad..e289c5e 100644 --- a/lib/solid_auth_client.dart +++ b/lib/solid_auth_client.dart @@ -36,8 +36,8 @@ 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:openid_client/openid_client.dart'; -import 'package:openid_client/openid_client_io.dart' as oidc_mobile; +// import 'package:openid_client/openid_client.dart'; +// import 'package:openid_client/openid_client_io.dart' as oidc_mobile; import 'package:url_launcher/url_launcher.dart'; import 'package:uuid/uuid.dart'; From d9ca30fa039f647cc329aeae4bd71ab7c1422ea0 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 24 Sep 2025 20:13:26 +1000 Subject: [PATCH 03/10] Add openid code --- lib/solid_auth_client.dart | 4 +- .../auth_manager/auth_manager_abstract.dart | 8 +- lib/src/auth_manager/auth_manager_stub.dart | 2 +- lib/src/auth_manager/web_auth_manager.dart | 5 +- lib/src/openid/openid_browser.dart | 100 --- lib/src/openid/openid_client.dart | 6 + lib/src/openid/openid_client_browser.dart | 161 ++++ lib/src/openid/openid_client_io.dart | 189 +++++ lib/src/openid/openid_common.dart | 92 --- lib/src/openid/openid_io.dart | 0 lib/src/openid/src/http_util.dart | 95 +++ lib/src/openid/src/model.dart | 12 + lib/src/openid/src/model/claims.dart | 183 +++++ lib/src/openid/src/model/metadata.dart | 230 ++++++ lib/src/openid/src/model/token.dart | 8 + lib/src/openid/src/model/token_response.dart | 41 + lib/src/openid/src/openid.dart | 774 ++++++++++++++++++ pubspec.yaml | 3 +- 18 files changed, 1709 insertions(+), 204 deletions(-) delete mode 100644 lib/src/openid/openid_browser.dart create mode 100644 lib/src/openid/openid_client.dart create mode 100644 lib/src/openid/openid_client_browser.dart create mode 100644 lib/src/openid/openid_client_io.dart delete mode 100644 lib/src/openid/openid_common.dart delete mode 100644 lib/src/openid/openid_io.dart create mode 100644 lib/src/openid/src/http_util.dart create mode 100644 lib/src/openid/src/model.dart create mode 100644 lib/src/openid/src/model/claims.dart create mode 100644 lib/src/openid/src/model/metadata.dart create mode 100644 lib/src/openid/src/model/token.dart create mode 100644 lib/src/openid/src/model/token_response.dart create mode 100644 lib/src/openid/src/openid.dart diff --git a/lib/solid_auth_client.dart b/lib/solid_auth_client.dart index e289c5e..9dd8a06 100644 --- a/lib/solid_auth_client.dart +++ b/lib/solid_auth_client.dart @@ -36,13 +36,13 @@ 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:openid_client/openid_client.dart'; -// import 'package:openid_client/openid_client_io.dart' as oidc_mobile; 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 diff --git a/lib/src/auth_manager/auth_manager_abstract.dart b/lib/src/auth_manager/auth_manager_abstract.dart index 27d44ea..0cddf59 100644 --- a/lib/src/auth_manager/auth_manager_abstract.dart +++ b/lib/src/auth_manager/auth_manager_abstract.dart @@ -28,11 +28,9 @@ library; // import just for the client class. Not used anywhere else. -// import 'package:openid_client/openid_client.dart'; - -import 'package:openid_client/openid_client.dart'; - -import 'auth_manager_stub.dart' if (dart.library.html) 'web_auth_manager.dart'; +import 'package:solid_auth/src/openid/src/openid.dart'; +import 'package:solid_auth/src/auth_manager/auth_manager_stub.dart' + if (dart.library.html) 'web_auth_manager.dart'; abstract class AuthManager { // some generic methods to be exposed. diff --git a/lib/src/auth_manager/auth_manager_stub.dart b/lib/src/auth_manager/auth_manager_stub.dart index 622a247..1f25ffc 100644 --- a/lib/src/auth_manager/auth_manager_stub.dart +++ b/lib/src/auth_manager/auth_manager_stub.dart @@ -27,7 +27,7 @@ /// Authors: Anushka Vidanage library; -import 'auth_manager_abstract.dart'; +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 index 0c4a168..c31d022 100644 --- a/lib/src/auth_manager/web_auth_manager.dart +++ b/lib/src/auth_manager/web_auth_manager.dart @@ -27,12 +27,11 @@ /// Authors: Anushka Vidanage library; -import 'package:openid_client/openid_client_browser.dart'; import 'package:openidconnect_web/openidconnect_web.dart'; import 'package:web/web.dart' hide Client; -// Project imports: -import 'auth_manager_abstract.dart'; +import 'package:solid_auth/src/openid/openid_client_browser.dart'; +import 'package:solid_auth/src/auth_manager/auth_manager_abstract.dart'; late Window windowLoc; diff --git a/lib/src/openid/openid_browser.dart b/lib/src/openid/openid_browser.dart deleted file mode 100644 index 0440d19..0000000 --- a/lib/src/openid/openid_browser.dart +++ /dev/null @@ -1,100 +0,0 @@ -/// Openid Client Browser management. Extention from the library `openid_client`. -/// -/// 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:openid_client/openid_client_browser.dart'; -import 'package:web/web.dart'; - -class AuthenticatorCustom extends Authenticator { - - AuthenticatorCustom._(this.flow) : super.credential = _credentialFromUri(flow); - - // With PKCE flow - AuthenticatorCustom( - 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; - } - -} \ No newline at end of file diff --git a/lib/src/openid/openid_client.dart b/lib/src/openid/openid_client.dart new file mode 100644 index 0000000..f0c8446 --- /dev/null +++ b/lib/src/openid/openid_client.dart @@ -0,0 +1,6 @@ +// Copyright (c) 2017, rbellens. All rights reserved. Use of this source code +// is governed by a BSD-style license that can be found in the LICENSE file. + +library openid_client; + +export 'src/openid.dart'; diff --git a/lib/src/openid/openid_client_browser.dart b/lib/src/openid/openid_client_browser.dart new file mode 100644 index 0000000..943e85f --- /dev/null +++ b/lib/src/openid/openid_client_browser.dart @@ -0,0 +1,161 @@ +import 'dart:js_interop'; + +import 'openid_client.dart'; +import 'package:web/web.dart' hide Credential, Client; +import 'dart:async'; +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 new file mode 100644 index 0000000..4dcc279 --- /dev/null +++ b/lib/src/openid/openid_client_io.dart @@ -0,0 +1,189 @@ +library openid_client.io; + +import 'dart:async'; +import 'dart:io'; +import 'dart:developer'; + +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/openid_common.dart b/lib/src/openid/openid_common.dart deleted file mode 100644 index 59872aa..0000000 --- a/lib/src/openid/openid_common.dart +++ /dev/null @@ -1,92 +0,0 @@ -/// Openid functions. Extention from the library `openid_client`. -/// -/// 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:convert'; - -import 'package:openid_client/openid_client.dart'; - -class CredentialCustom extends Credential { - CredentialCustom.fromJson(super.json) : super.fromJson(); - - Future getTokenResponseCustom({ - bool forceRefresh = false, - String dPoPToken = '', - }) async { - if (!forceRefresh && - response.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'; // TODO: make this selection more explicit - - ///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; - } -} diff --git a/lib/src/openid/openid_io.dart b/lib/src/openid/openid_io.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/src/openid/src/http_util.dart b/lib/src/openid/src/http_util.dart new file mode 100644 index 0000000..566b305 --- /dev/null +++ b/lib/src/openid/src/http_util.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +import '../openid_client.dart'; + +export 'package:http/http.dart' show Client; + +final _logger = Logger('openid_client'); + +typedef ClientFactory = http.Client Function(); + +Future get(Uri url, + {Map? headers, required http.Client? client}) async { + return _processResponse( + await _withClient((client) => client.get(url, headers: headers), client)); +} + +Future post(Uri url, + {Map? headers, + body, + Encoding? encoding, + required http.Client? client}) async { + return _processResponse(await _withClient( + (client) => + client.post(url, headers: headers, body: body, encoding: encoding), + client)); +} + +dynamic _processResponse(http.Response response) { + _logger.fine( + '${response.request!.method} ${response.request!.url}: ${response.body}'); + var contentType = response.headers.entries + .firstWhere((v) => v.key.toLowerCase() == 'content-type', + orElse: () => MapEntry('', '')) + .value; + var isJson = contentType.split(';').first == 'application/json'; + + var body = isJson ? json.decode(response.body) : response.body; + if (body is Map && body['error'] is String) { + throw OpenIdException( + body['error'], body['error_description'], body['error_uri']); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpRequestException(statusCode: response.statusCode, body: body); + } + return body; +} + +Future _withClient(Future Function(http.Client client) fn, + [http.Client? client0]) async { + var client = client0 ?? http.Client(); + try { + return await fn(client); + } finally { + if (client != client0) client.close(); + } +} + +class AuthorizedClient extends http.BaseClient { + final http.Client baseClient; + + final Credential credential; + + AuthorizedClient(this.baseClient, this.credential); + + @override + Future send(http.BaseRequest request) async { + var token = await credential.getTokenResponse(); + if (token.tokenType != null && token.tokenType!.toLowerCase() != 'bearer') { + throw UnsupportedError('Unknown token type: ${token.tokenType}'); + } + + request.headers['Authorization'] = 'Bearer ${token.accessToken}'; + + return baseClient.send(request); + } +} + +/// An exception thrown when a http request responds with a status code other +/// than successful (2xx) and the response is not in the openid error format. +class HttpRequestException implements Exception { + final int statusCode; + + final dynamic body; + + HttpRequestException({required this.statusCode, this.body}); + + @override + String toString() { + return 'HttpRequestException($statusCode): $body'; + } +} diff --git a/lib/src/openid/src/model.dart b/lib/src/openid/src/model.dart new file mode 100644 index 0000000..7bf2394 --- /dev/null +++ b/lib/src/openid/src/model.dart @@ -0,0 +1,12 @@ +library openid.model; + +import 'package:jose/src/util.dart'; +import 'package:jose/jose.dart'; +import 'package:clock/clock.dart'; + +part 'model/metadata.dart'; + +part 'model/token_response.dart'; + +part 'model/claims.dart'; +part 'model/token.dart'; diff --git a/lib/src/openid/src/model/claims.dart b/lib/src/openid/src/model/claims.dart new file mode 100644 index 0000000..11ea8fe --- /dev/null +++ b/lib/src/openid/src/model/claims.dart @@ -0,0 +1,183 @@ +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!; + + 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 new file mode 100644 index 0000000..1eeea47 --- /dev/null +++ b/lib/src/openid/src/model/metadata.dart @@ -0,0 +1,230 @@ +part of '../model.dart'; + +/// OpenID Provider Metadata +class OpenIdProviderMetadata extends JsonObject { + /// URL that the OP asserts as its OpenIdProviderMetadata Identifier. + Uri get issuer => getTyped('issuer')!; + + /// URL of the OP's OAuth 2.0 Authorization Endpoint. + Uri get authorizationEndpoint => getTyped('authorization_endpoint')!; + + /// URL of the OP's OAuth 2.0 Token Endpoint. + Uri? get tokenEndpoint => getTyped('token_endpoint'); + + /// URL of the OP's UserInfo Endpoint. + Uri? get userinfoEndpoint => getTyped('userinfo_endpoint'); + + /// URL of the OP's JSON Web Key Set document. + /// + /// This contains the signing key(s) the RP uses to validate signatures from the OP. + Uri? get jwksUri => getTyped('jwks_uri'); + + /// URL of the OP's Dynamic Client Registration Endpoint. + Uri? get registrationEndpoint => getTyped('registration_endpoint'); + + /// A list of the OAuth 2.0 scope values that this server supports. + List? get scopesSupported => getTypedList('scopes_supported'); + + /// A list of the OAuth 2.0 `response_type` values that this OP supports. + List get responseTypesSupported => + getTypedList('response_types_supported')!; + + /// A list of the OAuth 2.0 `response_mode` values that this OP supports. + List? get responseModesSupported => + getTypedList('response_modes_supported'); + + /// A list of the OAuth 2.0 Grant Type values that this OP supports. + List? get grantTypesSupported => + getTypedList('grant_types_supported'); + + /// A list of the Authentication Context Class References that this OP supports. + List? get acrValuesSupported => getTypedList('acr_values_supported'); + + /// A list of the Subject Identifier types that this OP supports. + /// + /// Valid types include `pairwise` and `public`. + List get subjectTypesSupported => + getTypedList('subject_types_supported')!; + + /// A list of the JWS signing algorithms (`alg` values) supported by the OP for + /// the ID Token to encode the Claims in a JWT. + /// + /// The algorithm `RS256` MUST be included. The value `none` MAY be supported, + /// but MUST NOT be used unless the Response Type used returns no ID Token + /// from the Authorization Endpoint (such as when using the Authorization Code + /// Flow). + List get idTokenSigningAlgValuesSupported => + getTypedList('id_token_signing_alg_values_supported')!; + + /// A list of the JWE encryption algorithms (`alg` values) supported by the OP + /// for the ID Token to encode the Claims in a JWT. + List? get idTokenEncryptionAlgValuesSupported => + getTypedList('id_token_encryption_alg_values_supported'); + + /// A list of the JWE encryption algorithms (`enc` values) supported by the OP + /// for the ID Token to encode the Claims in a JWT. + List? get idTokenEncryptionEncValuesSupported => + getTypedList('id_token_encryption_enc_values_supported'); + + /// A list of the JWS signing algorithms (`alg` values) supported by the + /// UserInfo Endpoint to encode the Claims in a JWT. + List? get userinfoSigningAlgValuesSupported => + getTypedList('userinfo_signing_alg_values_supported'); + + /// A list of the JWE encryption algorithms (`alg` values) supported by the + /// UserInfo Endpoint to encode the Claims in a JWT. + List? get userinfoEncryptionAlgValuesSupported => + getTypedList('userinfo_encryption_alg_values_supported'); + + /// A list of the JWE encryption algorithms (`enc` values) supported by the + /// UserInfo Endpoint to encode the Claims in a JWT. + List? get userinfoEncryptionEncValuesSupported => + getTypedList('userinfo_encryption_enc_values_supported'); + + /// A list of the JWS signing algorithms (`alg` values) supported by the OP + /// for Request Objects. + /// + /// These algorithms are used both when the Request Object is passed by value + /// (using the request parameter) and when it is passed by reference (using + /// the request_uri parameter). + List? get requestObjectSigningAlgValuesSupported => + getTypedList('request_object_signing_alg_values_supported'); + + /// A list of the JWE encryption algorithms (`alg` values) supported by the OP + /// for Request Objects. + /// + /// These algorithms are used both when the Request Object is passed by value + /// and when it is passed by reference. + List? get requestObjectEncryptionAlgValuesSupported => + getTypedList('request_object_encryption_alg_values_supported'); + + /// A list of the JWE encryption algorithms (`enc` values) supported by the OP + /// for Request Objects. + /// + /// These algorithms are used both when the Request Object is passed by value + /// and when it is passed by reference. + List? get requestObjectEncryptionEncValuesSupported => + getTypedList('request_object_encryption_enc_values_supported'); + + /// A list of Client Authentication methods supported by this Token Endpoint. + /// + /// The options are `client_secret_post`, `client_secret_basic`, + /// `client_secret_jwt`, and `private_key_jwt`. Other authentication methods + /// MAY be defined by extensions. + List? get tokenEndpointAuthMethodsSupported => + getTypedList('token_endpoint_auth_methods_supported'); + + /// A list of the JWS signing algorithms (`alg` values) supported by the Token + /// Endpoint for the signature on the JWT used to authenticate the Client at + /// the Token Endpoint for the `private_key_jwt` and `client_secret_jwt` + /// authentication methods. + List? get tokenEndpointAuthSigningAlgValuesSupported => + getTypedList('token_endpoint_auth_signing_alg_values_supported'); + + /// A list of the display parameter values that the OpenID Provider supports. + List? get displayValuesSupported => + getTypedList('display_values_supported'); + + /// A list of the Claim Types that the OpenID Provider supports. + /// + /// Values defined by the specification are `normal`, `aggregated`, and + /// `distributed`. If omitted, the implementation supports only `normal` Claims. + List? get claimTypesSupported => + getTypedList('claim_types_supported'); + + /// A list of the Claim Names of the Claims that the OpenID Provider MAY be + /// able to supply values for. + /// + /// Note that for privacy or other reasons, this might not be an exhaustive + /// list. + List? get claimsSupported => getTypedList('claims_supported'); + + /// URL of a page containing human-readable information that developers might + /// want or need to know when using the OpenID Provider. + Uri? get serviceDocumentation => getTyped('service_documentation'); + + /// Languages and scripts supported for values in Claims being returned. + /// + /// Not all languages and scripts are necessarily supported for all Claim values. + List? get claimsLocalesSupported => + getTypedList('claims_locales_supported'); + + /// Languages and scripts supported for the user interface. + List? get uiLocalesSupported => getTypedList('ui_locales_supported'); + + /// `true` when the OP supports use of the `claims` parameter. + bool get claimsParameterSupported => + this['claims_parameter_supported'] ?? false; + + /// `true` when the OP supports use of the `request` parameter. + bool get requestParameterSupported => + this['request_parameter_supported'] ?? false; + + /// `true` when the OP supports use of the `request_uri` parameter. + bool get requestUriParameterSupported => + this['request_uri_parameter_supported'] ?? true; + + /// `true` when the OP requires any `request_uri` values used to be + /// pre-registered using the request_uris registration parameter. + bool get requireRequestUriRegistration => + this['require_request_uri_registration'] ?? false; + + /// URL that the OpenID Provider provides to the person registering the Client + /// to read about the OP's requirements on how the Relying Party can use the + /// data provided by the OP. + Uri? get opPolicyUri => getTyped('op_policy_uri'); + + /// URL that the OpenID Provider provides to the person registering the Client + /// to read about OpenID Provider's terms of service. + Uri? get opTosUri => getTyped('op_tos_uri'); + + /// URL of an OP iframe that supports cross-origin communications for session + /// state information with the RP Client, using the HTML5 postMessage API. + /// + /// The page is loaded from an invisible iframe embedded in an RP page so that + /// it can run in the OP's security context. It accepts postMessage requests + /// from the relevant RP iframe and uses postMessage to post back the login + /// status of the End-User at the OP. + Uri? get checkSessionIframe => getTyped('check_session_iframe'); + + /// URL at the OP to which an RP can perform a redirect to request that the + /// End-User be logged out at the OP. + Uri? get endSessionEndpoint => getTyped('end_session_endpoint'); + + /// URL of the authorization server's OAuth 2.0 revocation endpoint. + Uri? get revocationEndpoint => getTyped('revocation_endpoint'); + + /// A list of client authentication methods supported by this revocation + /// endpoint. + List? get revocationEndpointAuthMethodsSupported => + getTypedList('revocation_endpoint_auth_methods_supported'); + + /// A list of the JWS signing algorithms (`alg` values) supported by the + /// revocation endpoint for the signature on the JWT used to authenticate the + /// client at the revocation endpoint for the `private_key_jwt` and + /// `client_secret_jwt` authentication methods. + List? get revocationEndpointAuthSigningAlgValuesSupported => + getTypedList('revocation_endpoint_auth_signing_alg_values_supported'); + + /// URL of the authorization server's OAuth 2.0 introspection endpoint. + Uri? get introspectionEndpoint => getTyped('introspection_endpoint'); + + /// A list of client authentication methods supported by this introspection + /// endpoint. + List? get introspectionEndpointAuthMethodsSupported => + getTypedList('introspection_endpoint_auth_methods_supported'); + + /// A list of the JWS signing algorithms (`alg` values) supported by the + /// introspection endpoint for the signature on the JWT used to authenticate + /// the client at the introspection endpoint for the `private_key_jwt` and + /// `client_secret_jwt` authentication methods. + List? get introspectionEndpointAuthSigningAlgValuesSupported => + getTypedList('introspection_endpoint_auth_signing_alg_values_supported'); + + /// A list of PKCE code challenge methods supported by this authorization + /// server. + List? get codeChallengeMethodsSupported => + getTypedList('code_challenge_methods_supported'); + + OpenIdProviderMetadata.fromJson(Map json) : super.from(json); +} diff --git a/lib/src/openid/src/model/token.dart b/lib/src/openid/src/model/token.dart new file mode 100644 index 0000000..eb7fe6f --- /dev/null +++ b/lib/src/openid/src/model/token.dart @@ -0,0 +1,8 @@ +part of '../model.dart'; + +class IdToken extends JsonWebToken { + IdToken.unverified(String serialization) : super.unverified(serialization); + + @override + OpenIdClaims get claims => OpenIdClaims.fromJson(super.claims.toJson()); +} diff --git a/lib/src/openid/src/model/token_response.dart b/lib/src/openid/src/model/token_response.dart new file mode 100644 index 0000000..978fb1f --- /dev/null +++ b/lib/src/openid/src/model/token_response.dart @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..328510e --- /dev/null +++ b/lib/src/openid/src/openid.dart @@ -0,0 +1,774 @@ +library openid_client.openid; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:jose/jose.dart'; +import 'package:pointycastle/digests/sha256.dart'; + +import 'http_util.dart' as http; +import 'model.dart'; + +export 'model.dart'; +export 'http_util.dart' show HttpRequestException; + +/// Represents an OpenId Provider +class Issuer { + /// The OpenId Provider's metadata + final OpenIdProviderMetadata metadata; + + final Map claimsMap; + + final JsonWebKeyStore _keyStore; + + /// Creates an issuer from its metadata. + Issuer(this.metadata, {this.claimsMap = const {}}) + : _keyStore = metadata.jwksUri == null + ? JsonWebKeyStore() + : (JsonWebKeyStore()..addKeySetUrl(metadata.jwksUri!)); + + /// Url of the facebook issuer. + /// + /// Note: facebook does not support OpenID Connect, but the authentication + /// works. + static final Uri facebook = Uri.parse('https://www.facebook.com'); + + /// Url of the google issuer. + static final Uri google = Uri.parse('https://accounts.google.com'); + + /// Url of the yahoo issuer. + static final Uri yahoo = Uri.parse('https://api.login.yahoo.com'); + + /// Url of the microsoft issuer. + static final Uri microsoft = + Uri.parse('https://login.microsoftonline.com/common'); + + /// Url of the salesforce issuer. + static final Uri salesforce = Uri.parse('https://login.salesforce.com'); + + static Uri firebase(String id) => + Uri.parse('https://securetoken.google.com/$id'); + + static final Map _discoveries = { + facebook: Issuer(OpenIdProviderMetadata.fromJson({ + 'issuer': facebook.toString(), + 'authorization_endpoint': 'https://www.facebook.com/v2.8/dialog/oauth', + 'token_endpoint': 'https://graph.facebook.com/v2.8/oauth/access_token', + 'userinfo_endpoint': 'https://graph.facebook.com/v2.8/879023912133394', + 'response_types_supported': ['token', 'code', 'code token'], + 'token_endpoint_auth_methods_supported': ['client_secret_post'], + 'scopes_supported': [ + 'public_profile', + 'user_friends', + 'email', + 'user_about_me', + 'user_actions.books', + 'user_actions.fitness', + 'user_actions.music', + 'user_actions.news', + 'user_actions.video', + 'user_birthday', + 'user_education_history', + 'user_events', + 'user_games_activity', + 'user_hometown', + 'user_likes', + 'user_location', + 'user_managed_groups', + 'user_photos', + 'user_posts', + 'user_relationships', + 'user_relationship_details', + 'user_religion_politics', + 'user_tagged_places', + 'user_videos', + 'user_website', + 'user_work_history', + 'read_custom_friendlists', + 'read_insights', + 'read_audience_network_insights', + 'read_page_mailboxes', + 'manage_pages', + 'publish_pages', + 'publish_actions', + 'rsvp_event', + 'pages_show_list', + 'pages_manage_cta', + 'pages_manage_instant_articles', + 'ads_read', + 'ads_management', + 'business_management', + 'pages_messaging', + 'pages_messaging_subscriptions', + 'pages_messaging_phone_number' + ] + })), + google: null, + yahoo: null, + microsoft: null, + salesforce: null + }; + + static Iterable get knownIssuers => _discoveries.keys; + + /// Discovers the OpenId Provider's metadata based on its uri. + static Future discover(Uri uri, {http.Client? httpClient}) async { + if (_discoveries[uri] != null) return _discoveries[uri]!; + + var segments = uri.pathSegments.toList(); + if (segments.isNotEmpty && segments.last.isEmpty) { + segments.removeLast(); + } + segments.addAll(['.well-known', 'openid-configuration']); + uri = uri.replace(pathSegments: segments); + + var json = await http.get(uri, client: httpClient); + return _discoveries[uri] = Issuer(OpenIdProviderMetadata.fromJson(json)); + } +} + +/// Represents the client application. +class Client { + /// The id of the client. + final String clientId; + + /// A secret for authenticating the client to the OP. + final String? clientSecret; + + /// The [Issuer] representing the OP. + final Issuer issuer; + + final http.Client? httpClient; + + Client(this.issuer, this.clientId, {this.clientSecret, this.httpClient}); + + static Future forIdToken(String idToken, + {http.Client? httpClient}) async { + var token = JsonWebToken.unverified(idToken); + var claims = OpenIdClaims.fromJson(token.claims.toJson()); + var issuer = await Issuer.discover(claims.issuer, httpClient: httpClient); + if (!await token.verify(issuer._keyStore)) { + throw ArgumentError('Unable to verify token'); + } + var clientId = claims.authorizedParty ?? claims.audience.single; + return Client(issuer, clientId, httpClient: httpClient); + } + + /// Creates a [Credential] for this client. + Credential createCredential( + {String? accessToken, + String? tokenType, + String? refreshToken, + Duration? expiresIn, + DateTime? expiresAt, + String? idToken}) => + Credential._( + this, + TokenResponse.fromJson({ + 'access_token': accessToken, + 'token_type': tokenType, + 'refresh_token': refreshToken, + 'id_token': idToken, + if (expiresIn != null) 'expires_in': expiresIn.inSeconds, + if (expiresAt != null) + 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 + }), + null); +} + +class Credential { + TokenResponse _token; + final Client client; + final String? nonce; + + final StreamController _onTokenChanged = + StreamController.broadcast(); + + Credential._(this.client, this._token, this.nonce); + + Map? get response => _token.toJson(); + + Future getUserInfo() async { + var uri = client.issuer.metadata.userinfoEndpoint; + if (uri == null) { + throw UnsupportedError('Issuer does not support userinfo endpoint.'); + } + return UserInfo.fromJson(await _get(uri)); + } + + /// Emits a new [TokenResponse] every time the token is refreshed + Stream get onTokenChanged => _onTokenChanged.stream; + + /// Allows clients to notify the authorization server that a previously + /// obtained refresh or access token is no longer needed + /// + /// See https://tools.ietf.org/html/rfc7009 + Future revoke() async { + var 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'; // TODO: make this selection more explicit + + ///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 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(); +} + +/// 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/pubspec.yaml b/pubspec.yaml index 5e98eb1..83eb065 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,11 +14,12 @@ dependencies: dart_jsonwebtoken: ^3.2.0 fast_rsa: ^3.8.1 http: ^1.3.0 - openid_client: ^0.4.9+1 + jose: ^0.3.5 # openid_client: # git: # url: https://github.com/anusii/openid_client # ref: av/custom_edits_for_solid_auth + logging: ^1.3.0 openidconnect_web: ^1.0.26 url_launcher: ^6.3.1 uuid: ^4.5.1 From b5e04dcb7b5e9b3e6c3421093ad1ef30090cf643 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 24 Sep 2025 20:26:00 +1000 Subject: [PATCH 04/10] Acknowledgement --- example/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/example/README.md b/example/README.md index 251b498..b51279a 100644 --- a/example/README.md +++ b/example/README.md @@ -19,6 +19,10 @@ Flutter Demo app for authenticate with Solid PODs. The app can run on both mobil - uuid: ^3.0.4 - openidconnect_platform_interface: ^1.0.3 --> +### Acknowledgement + +Parts of this package (under `lib/src/openid`) are derived from the `openid_client` (https://pub.dev/packages/openid_client) package, with custom modifications to support the Solid OIDC authentication flow. We would like to acknowledge and thank the authors of `openid_client` for their valuable work, which made these adaptations possible. + ## Usage Fork the demo app in the `\example` directory into a local directory and open the `pubspec.yaml` file. Check whether the latest version of the package `solid_auth` is added under dependencies. Now run the following to get the necessary packages. From 5346eeffbce746902bbee591d124ff7fdc0d84eb Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 24 Sep 2025 20:48:45 +1000 Subject: [PATCH 05/10] fix lint issues --- lib/src/openid/openid_client.dart | 1 + lib/src/openid/openid_client_browser.dart | 38 +- lib/src/openid/openid_client_io.dart | 35 +- lib/src/openid/src/http_util.dart | 49 +- lib/src/openid/src/model.dart | 3 + lib/src/openid/src/model/claims.dart | 17 +- lib/src/openid/src/model/metadata.dart | 1 + lib/src/openid/src/model/token.dart | 1 + lib/src/openid/src/model/token_response.dart | 7 +- lib/src/openid/src/openid.dart | 516 ++++++++++--------- pubspec.yaml | 1 + 11 files changed, 388 insertions(+), 281 deletions(-) diff --git a/lib/src/openid/openid_client.dart b/lib/src/openid/openid_client.dart index f0c8446..aef0e30 100644 --- a/lib/src/openid/openid_client.dart +++ b/lib/src/openid/openid_client.dart @@ -1,6 +1,7 @@ // 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 index 943e85f..8c7dbef 100644 --- a/lib/src/openid/openid_client_browser.dart +++ b/lib/src/openid/openid_client_browser.dart @@ -77,7 +77,8 @@ class Authenticator { var c = await credential; if (c == null) return; var uri = c.generateLogoutUrl( - redirectUri: Uri.parse(window.location.href).removeFragment()); + redirectUri: Uri.parse(window.location.href).removeFragment(), + ); if (uri != null) { window.location.href = uri.toString(); } @@ -96,8 +97,11 @@ class Authenticator { if (q.containsKey('access_token') || q.containsKey('code') || q.containsKey('id_token')) { - window.history.replaceState(''.toJS, '', - Uri.parse(window.location.href).removeFragment().toString()); + window.history.replaceState( + ''.toJS, + '', + Uri.parse(window.location.href).removeFragment().toString(), + ); window.localStorage.removeItem('openid_client:state'); var c = await flow.callback(q.cast()); @@ -115,19 +119,24 @@ class Authenticator { /// 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 { + 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.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(() { @@ -143,7 +152,8 @@ class Authenticator { expiresAt: data['expires_at'] == null ? null : DateTime.fromMillisecondsSinceEpoch( - int.parse(data['expires_at'].toString()) * 1000), + int.parse(data['expires_at'].toString()) * 1000, + ), refreshToken: data['refresh_token'], expiresIn: data['expires_in'] == null ? null diff --git a/lib/src/openid/openid_client_io.dart b/lib/src/openid/openid_client_io.dart index 4dcc279..59db3f1 100644 --- a/lib/src/openid/openid_client_io.dart +++ b/lib/src/openid/openid_client_io.dart @@ -1,3 +1,4 @@ +// ignore: unnecessary_library_name library openid_client.io; import 'dart:async'; @@ -73,10 +74,16 @@ class Authenticator { ), 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) + ? 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; @@ -119,7 +126,10 @@ class Authenticator { {}; static Future _startServer( - int port, String? htmlPage, String? redirectMessage) { + int port, + String? htmlPage, + String? redirectMessage, + ) { return _requestServers[port] ??= (HttpServer.bind(InternetAddress.anyIPv4, port) ..then((requestServer) async { @@ -128,11 +138,13 @@ class Authenticator { request.response.statusCode = 200; if (redirectMessage != null) { request.response.headers.contentType = ContentType.html; - request.response.writeln(htmlPage ?? - '' - '

$redirectMessage

' - '' - ''); + request.response.writeln( + htmlPage ?? + '' + '

$redirectMessage

' + '' + '', + ); } await request.response.close(); var result = request.requestedUri.queryParameters; @@ -178,7 +190,8 @@ void _runBrowser(String url) { break; default: throw UnsupportedError( - 'Unsupported platform: ${Platform.operatingSystem}'); + 'Unsupported platform: ${Platform.operatingSystem}', + ); } } diff --git a/lib/src/openid/src/http_util.dart b/lib/src/openid/src/http_util.dart index 566b305..c4d15a7 100644 --- a/lib/src/openid/src/http_util.dart +++ b/lib/src/openid/src/http_util.dart @@ -12,36 +12,51 @@ final _logger = Logger('openid_client'); typedef ClientFactory = http.Client Function(); -Future get(Uri url, - {Map? headers, required http.Client? client}) async { +Future get( + Uri url, { + Map? headers, + required http.Client? client, +}) async { return _processResponse( - await _withClient((client) => client.get(url, headers: headers), client)); + 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( +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)); + client, + ), + ); } dynamic _processResponse(http.Response response) { _logger.fine( - '${response.request!.method} ${response.request!.url}: ${response.body}'); + '${response.request!.method} ${response.request!.url}: ${response.body}', + ); var contentType = response.headers.entries - .firstWhere((v) => v.key.toLowerCase() == 'content-type', - orElse: () => MapEntry('', '')) + .firstWhere( + (v) => v.key.toLowerCase() == 'content-type', + orElse: () => MapEntry('', ''), + ) .value; var isJson = contentType.split(';').first == 'application/json'; var body = isJson ? json.decode(response.body) : response.body; if (body is Map && body['error'] is String) { throw OpenIdException( - body['error'], body['error_description'], body['error_uri']); + body['error'], + body['error_description'], + body['error_uri'], + ); } if (response.statusCode < 200 || response.statusCode >= 300) { throw HttpRequestException(statusCode: response.statusCode, body: body); @@ -49,8 +64,10 @@ dynamic _processResponse(http.Response response) { return body; } -Future _withClient(Future Function(http.Client client) fn, - [http.Client? client0]) async { +Future _withClient( + Future Function(http.Client client) fn, [ + http.Client? client0, +]) async { var client = client0 ?? http.Client(); try { return await fn(client); diff --git a/lib/src/openid/src/model.dart b/lib/src/openid/src/model.dart index 7bf2394..848a157 100644 --- a/lib/src/openid/src/model.dart +++ b/lib/src/openid/src/model.dart @@ -1,3 +1,6 @@ +// ignore_for_file: unnecessary_library_name +// ignore_for_file: implementation_imports + library openid.model; import 'package:jose/src/util.dart'; diff --git a/lib/src/openid/src/model/claims.dart b/lib/src/openid/src/model/claims.dart index 11ea8fe..b48b34f 100644 --- a/lib/src/openid/src/model/claims.dart +++ b/lib/src/openid/src/model/claims.dart @@ -158,16 +158,21 @@ class OpenIdClaims extends JsonWebTokenClaims @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* { + Iterable validate({ + Duration expiryTolerance = const Duration(), + Uri? issuer, + String? clientId, + String? nonce, + }) sync* { yield* super.validate( - expiryTolerance: expiryTolerance, issuer: issuer, clientId: clientId); + expiryTolerance: expiryTolerance, + issuer: issuer, + clientId: clientId, + ); if (audience.length > 1 && authorizedParty == null) { yield JoseException('No authorized party claim present.'); } diff --git a/lib/src/openid/src/model/metadata.dart b/lib/src/openid/src/model/metadata.dart index 1eeea47..1667bbc 100644 --- a/lib/src/openid/src/model/metadata.dart +++ b/lib/src/openid/src/model/metadata.dart @@ -226,5 +226,6 @@ class OpenIdProviderMetadata extends JsonObject { 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 index eb7fe6f..5264f4f 100644 --- a/lib/src/openid/src/model/token.dart +++ b/lib/src/openid/src/model/token.dart @@ -1,6 +1,7 @@ part of '../model.dart'; class IdToken extends JsonWebToken { + // ignore: use_super_parameters IdToken.unverified(String serialization) : super.unverified(serialization); @override diff --git a/lib/src/openid/src/model/token_response.dart b/lib/src/openid/src/model/token_response.dart index 978fb1f..ea519af 100644 --- a/lib/src/openid/src/model/token_response.dart +++ b/lib/src/openid/src/model/token_response.dart @@ -30,10 +30,13 @@ class TokenResponse extends JsonObject { : super.from({ if (json['expires_in'] != null && json['expires_at'] == null) 'expires_at': DateTime.now() - .add(Duration( + .add( + Duration( seconds: json['expires_in'] is String ? int.parse(json['expires_in']) - : 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 index 328510e..9bd8b1b 100644 --- a/lib/src/openid/src/openid.dart +++ b/lib/src/openid/src/openid.dart @@ -1,3 +1,6 @@ +// ignore_for_file: depend_on_referenced_packages + +// ignore: unnecessary_library_name library openid_client.openid; import 'dart:async'; @@ -52,63 +55,65 @@ class Issuer { Uri.parse('https://securetoken.google.com/$id'); static final Map _discoveries = { - facebook: Issuer(OpenIdProviderMetadata.fromJson({ - 'issuer': facebook.toString(), - 'authorization_endpoint': 'https://www.facebook.com/v2.8/dialog/oauth', - 'token_endpoint': 'https://graph.facebook.com/v2.8/oauth/access_token', - 'userinfo_endpoint': 'https://graph.facebook.com/v2.8/879023912133394', - 'response_types_supported': ['token', 'code', 'code token'], - 'token_endpoint_auth_methods_supported': ['client_secret_post'], - 'scopes_supported': [ - 'public_profile', - 'user_friends', - 'email', - 'user_about_me', - 'user_actions.books', - 'user_actions.fitness', - 'user_actions.music', - 'user_actions.news', - 'user_actions.video', - 'user_birthday', - 'user_education_history', - 'user_events', - 'user_games_activity', - 'user_hometown', - 'user_likes', - 'user_location', - 'user_managed_groups', - 'user_photos', - 'user_posts', - 'user_relationships', - 'user_relationship_details', - 'user_religion_politics', - 'user_tagged_places', - 'user_videos', - 'user_website', - 'user_work_history', - 'read_custom_friendlists', - 'read_insights', - 'read_audience_network_insights', - 'read_page_mailboxes', - 'manage_pages', - 'publish_pages', - 'publish_actions', - 'rsvp_event', - 'pages_show_list', - 'pages_manage_cta', - 'pages_manage_instant_articles', - 'ads_read', - 'ads_management', - 'business_management', - 'pages_messaging', - 'pages_messaging_subscriptions', - 'pages_messaging_phone_number' - ] - })), + facebook: Issuer( + OpenIdProviderMetadata.fromJson({ + 'issuer': facebook.toString(), + 'authorization_endpoint': 'https://www.facebook.com/v2.8/dialog/oauth', + 'token_endpoint': 'https://graph.facebook.com/v2.8/oauth/access_token', + 'userinfo_endpoint': 'https://graph.facebook.com/v2.8/879023912133394', + 'response_types_supported': ['token', 'code', 'code token'], + 'token_endpoint_auth_methods_supported': ['client_secret_post'], + 'scopes_supported': [ + 'public_profile', + 'user_friends', + 'email', + 'user_about_me', + 'user_actions.books', + 'user_actions.fitness', + 'user_actions.music', + 'user_actions.news', + 'user_actions.video', + 'user_birthday', + 'user_education_history', + 'user_events', + 'user_games_activity', + 'user_hometown', + 'user_likes', + 'user_location', + 'user_managed_groups', + 'user_photos', + 'user_posts', + 'user_relationships', + 'user_relationship_details', + 'user_religion_politics', + 'user_tagged_places', + 'user_videos', + 'user_website', + 'user_work_history', + 'read_custom_friendlists', + 'read_insights', + 'read_audience_network_insights', + 'read_page_mailboxes', + 'manage_pages', + 'publish_pages', + 'publish_actions', + 'rsvp_event', + 'pages_show_list', + 'pages_manage_cta', + 'pages_manage_instant_articles', + 'ads_read', + 'ads_management', + 'business_management', + 'pages_messaging', + 'pages_messaging_subscriptions', + 'pages_messaging_phone_number', + ], + }), + ), google: null, yahoo: null, microsoft: null, - salesforce: null + salesforce: null, }; static Iterable get knownIssuers => _discoveries.keys; @@ -144,8 +149,10 @@ class Client { Client(this.issuer, this.clientId, {this.clientSecret, this.httpClient}); - static Future forIdToken(String idToken, - {http.Client? httpClient}) async { + 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); @@ -157,25 +164,27 @@ class Client { } /// Creates a [Credential] for this client. - Credential createCredential( - {String? accessToken, - String? tokenType, - String? refreshToken, - Duration? expiresIn, - DateTime? expiresAt, - String? idToken}) => + 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); + 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 { @@ -219,19 +228,22 @@ class Credential { 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); + 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); + await http.post( + uri, + body: { + ...request, + 'client_id': client.clientId, + if (client.clientSecret != null) 'client_secret': client.clientSecret, + }, + client: client.httpClient, + ); } } @@ -246,17 +258,21 @@ class Credential { /// /// 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 - }); + 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); + baseClient ?? client.httpClient ?? http.Client(), + this, + ); Future _get(Uri uri) async { return http.get(uri, client: createHttpClient()); @@ -264,28 +280,36 @@ class Credential { IdToken get idToken => _token.idToken; - Stream validateToken( - {bool validateClaims = true, bool validateExpiry = true}) async* { + 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)) { + if (!await idToken.verify( + keyStore, + allowedArguments: client.issuer.metadata.idTokenSigningAlgValuesSupported, + )) { yield JoseException('Could not verify token signature'); } - yield* Stream.fromIterable(idToken.claims - .validate( + 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.')))); + nonce: nonce, + ) + .where( + (e) => + validateExpiry || + !(e is JoseException && e.message.startsWith('JWT expired.')), + ), + ); } String? get refreshToken => _token.refreshToken; @@ -307,9 +331,8 @@ class Credential { var h = base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); - var grantType = _token.refreshToken != null - ? 'refresh_token' - : 'client_credentials'; // TODO: make this selection more explicit + var grantType = + _token.refreshToken != null ? 'refresh_token' : 'client_credentials'; ///Generate DPoP token using the RSA private key var json = await http.post( @@ -359,21 +382,24 @@ class Credential { 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']); + 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 + 'nonce': nonce, }; } @@ -454,7 +480,7 @@ class Flow { .replaceAll('=', ''); _proofKeyForCodeExchange = { 'code_verifier': verifier, - 'code_challenge': challenge + 'code_challenge': challenge, }; } @@ -463,31 +489,37 @@ class 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._( + 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.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, { @@ -505,32 +537,34 @@ class Flow { codeVerifier: codeVerifier, additionalParameters: { if (prompt != null) 'prompt': prompt, - ...?additionalParameters + ...?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, - }); + 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); @@ -551,14 +585,15 @@ class Flow { 'scope': scopes.join(' '), 'client_id': client.clientId, 'redirect_uri': redirectUri.toString(), - 'state': state + 'state': state, }..addAll( - responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); + responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}, + ); if (type == FlowType.proofKeyForCodeExchange) { v.addAll({ 'code_challenge_method': 'S256', - 'code_challenge': _proofKeyForCodeExchange['code_challenge'] + 'code_challenge': _proofKeyForCodeExchange['code_challenge'], }); } return v; @@ -568,65 +603,74 @@ class Flow { 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); + 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); + 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); + 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); + 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); + 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'); } @@ -636,20 +680,24 @@ class Flow { /// Login with username and password /// /// Only allowed for [Flow.password] flows. - Future loginWithPassword( - {required String username, required String password}) async { + 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); + 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); } @@ -657,14 +705,16 @@ class Flow { 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); + 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); } @@ -761,8 +811,10 @@ class OpenIdException implements Exception { /// 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.'); + : this._( + 'missing_token_endpoint', + 'The issuer metadata does not contain a token endpoint.', + ); const OpenIdException._(this.code, this.message) : uri = null; diff --git a/pubspec.yaml b/pubspec.yaml index 83eb065..4fd7d53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: flutter: sdk: flutter + clock: ^1.1.2 dart_jsonwebtoken: ^3.2.0 fast_rsa: ^3.8.1 http: ^1.3.0 From 607d717e2f969b5695750f3e925ca225a9cf7228 Mon Sep 17 00:00:00 2001 From: Graham Williams Date: Thu, 25 Sep 2025 05:13:23 +1000 Subject: [PATCH 06/10] Update with import order automatic fixes. --- lib/src/auth_manager/auth_manager_abstract.dart | 4 ++-- lib/src/auth_manager/web_auth_manager.dart | 2 +- lib/src/openid/openid_client_browser.dart | 6 ++++-- lib/src/openid/openid_client_io.dart | 2 +- lib/src/openid/src/model.dart | 4 ++-- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/src/auth_manager/auth_manager_abstract.dart b/lib/src/auth_manager/auth_manager_abstract.dart index 0cddf59..d880fb9 100644 --- a/lib/src/auth_manager/auth_manager_abstract.dart +++ b/lib/src/auth_manager/auth_manager_abstract.dart @@ -27,10 +27,10 @@ /// Authors: Anushka Vidanage library; -// import just for the client class. Not used anywhere else. -import 'package:solid_auth/src/openid/src/openid.dart'; 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. diff --git a/lib/src/auth_manager/web_auth_manager.dart b/lib/src/auth_manager/web_auth_manager.dart index c31d022..f48d3b2 100644 --- a/lib/src/auth_manager/web_auth_manager.dart +++ b/lib/src/auth_manager/web_auth_manager.dart @@ -30,8 +30,8 @@ library; import 'package:openidconnect_web/openidconnect_web.dart'; import 'package:web/web.dart' hide Client; -import 'package:solid_auth/src/openid/openid_client_browser.dart'; import 'package:solid_auth/src/auth_manager/auth_manager_abstract.dart'; +import 'package:solid_auth/src/openid/openid_client_browser.dart'; late Window windowLoc; diff --git a/lib/src/openid/openid_client_browser.dart b/lib/src/openid/openid_client_browser.dart index 8c7dbef..d2e4a22 100644 --- a/lib/src/openid/openid_client_browser.dart +++ b/lib/src/openid/openid_client_browser.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'dart:js_interop'; -import 'openid_client.dart'; import 'package:web/web.dart' hide Credential, Client; -import 'dart:async'; + +import 'openid_client.dart'; + export 'openid_client.dart'; /// A wrapper around [Flow] that handles the browser-specific parts of diff --git a/lib/src/openid/openid_client_io.dart b/lib/src/openid/openid_client_io.dart index 59db3f1..bace17f 100644 --- a/lib/src/openid/openid_client_io.dart +++ b/lib/src/openid/openid_client_io.dart @@ -2,8 +2,8 @@ library openid_client.io; import 'dart:async'; -import 'dart:io'; import 'dart:developer'; +import 'dart:io'; import 'openid_client.dart'; diff --git a/lib/src/openid/src/model.dart b/lib/src/openid/src/model.dart index 848a157..388c2a3 100644 --- a/lib/src/openid/src/model.dart +++ b/lib/src/openid/src/model.dart @@ -3,9 +3,9 @@ library openid.model; -import 'package:jose/src/util.dart'; -import 'package:jose/jose.dart'; import 'package:clock/clock.dart'; +import 'package:jose/jose.dart'; +import 'package:jose/src/util.dart'; part 'model/metadata.dart'; From 5e05873c40c8dc05084f43cef7fd554ec8333ea8 Mon Sep 17 00:00:00 2001 From: Graham Williams Date: Thu, 25 Sep 2025 08:25:29 +1000 Subject: [PATCH 07/10] Improved license/copyright check --- support/flutter.mk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/support/flutter.mk b/support/flutter.mk index c6c8aa4..3297899 100644 --- a/support/flutter.mk +++ b/support/flutter.mk @@ -285,7 +285,7 @@ todo: license: @echo "Files without a LICENSE:\n" @-output=$$(find lib -type f -not -name '*~' -not -name 'README*' -not -name '*.g.dart' \ - ! -exec grep -qE '^(/// Copyright|/// Licensed)' {} \; -print | xargs printf "\t%s\n"); \ + ! -exec grep -qE '^(///? Copyright|///? Licensed)' {} \; -print | xargs printf "\t%s\n"); \ if [ $$(echo "$$output" | wc -w) -ne 0 ]; then \ echo "$$output"; \ echo "\n$(CROSS) Error: Files with no license found."; \ From 578b4458b78460f343a75a48362ece3d29f11ab9 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Thu, 25 Sep 2025 10:53:39 +1000 Subject: [PATCH 08/10] Add licensing --- lib/src/openid/openid_client_browser.dart | 25 ++++++++++++++ lib/src/openid/openid_client_io.dart | 35 +++++++++++++++++--- lib/src/openid/src/http_util.dart | 25 ++++++++++++++ lib/src/openid/src/model.dart | 25 ++++++++++++++ lib/src/openid/src/model/claims.dart | 25 ++++++++++++++ lib/src/openid/src/model/metadata.dart | 25 ++++++++++++++ lib/src/openid/src/model/token.dart | 25 ++++++++++++++ lib/src/openid/src/model/token_response.dart | 25 ++++++++++++++ lib/src/openid/src/openid.dart | 25 ++++++++++++++ 9 files changed, 230 insertions(+), 5 deletions(-) diff --git a/lib/src/openid/openid_client_browser.dart b/lib/src/openid/openid_client_browser.dart index 8c7dbef..0e724fa 100644 --- a/lib/src/openid/openid_client_browser.dart +++ b/lib/src/openid/openid_client_browser.dart @@ -1,3 +1,28 @@ +// 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:js_interop'; import 'openid_client.dart'; diff --git a/lib/src/openid/openid_client_io.dart b/lib/src/openid/openid_client_io.dart index 59db3f1..bf56a1d 100644 --- a/lib/src/openid/openid_client_io.dart +++ b/lib/src/openid/openid_client_io.dart @@ -1,3 +1,28 @@ +// 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; @@ -195,8 +220,8 @@ void _runBrowser(String url) { } } -extension FlowX on Flow { - Future authorize({Function(String url)? urlLauncher}) { - return Authenticator.fromFlow(this, urlLancher: urlLauncher).authorize(); - } -} +// 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 index c4d15a7..a66fccf 100644 --- a/lib/src/openid/src/http_util.dart +++ b/lib/src/openid/src/http_util.dart @@ -1,3 +1,28 @@ +// 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'; diff --git a/lib/src/openid/src/model.dart b/lib/src/openid/src/model.dart index 848a157..c782387 100644 --- a/lib/src/openid/src/model.dart +++ b/lib/src/openid/src/model.dart @@ -1,3 +1,28 @@ +// 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 diff --git a/lib/src/openid/src/model/claims.dart b/lib/src/openid/src/model/claims.dart index b48b34f..ec80431 100644 --- a/lib/src/openid/src/model/claims.dart +++ b/lib/src/openid/src/model/claims.dart @@ -1,3 +1,28 @@ +// 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 { diff --git a/lib/src/openid/src/model/metadata.dart b/lib/src/openid/src/model/metadata.dart index 1667bbc..e915fe1 100644 --- a/lib/src/openid/src/model/metadata.dart +++ b/lib/src/openid/src/model/metadata.dart @@ -1,3 +1,28 @@ +// 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 diff --git a/lib/src/openid/src/model/token.dart b/lib/src/openid/src/model/token.dart index 5264f4f..fc8f5d1 100644 --- a/lib/src/openid/src/model/token.dart +++ b/lib/src/openid/src/model/token.dart @@ -1,3 +1,28 @@ +// 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 { diff --git a/lib/src/openid/src/model/token_response.dart b/lib/src/openid/src/model/token_response.dart index ea519af..99570c2 100644 --- a/lib/src/openid/src/model/token_response.dart +++ b/lib/src/openid/src/model/token_response.dart @@ -1,3 +1,28 @@ +// 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 { diff --git a/lib/src/openid/src/openid.dart b/lib/src/openid/src/openid.dart index 9bd8b1b..e40ed73 100644 --- a/lib/src/openid/src/openid.dart +++ b/lib/src/openid/src/openid.dart @@ -1,3 +1,28 @@ +// 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 From bfc9998a584892e8824f3b65e3929a020b570e74 Mon Sep 17 00:00:00 2001 From: Graham Williams Date: Thu, 25 Sep 2025 11:07:04 +1000 Subject: [PATCH 09/10] Add app name and version to output from Make. --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b8277c3..98769da 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # # Generic Makefile # -# Time-stamp: +# Time-stamp: # # Copyright (c) Graham.Williams@togaware.com # @@ -114,6 +114,7 @@ apk:: rm -f installers/$(APP).apk deb: + @echo "Build $(APP) version $(VER)" (cd installers; make $@) rsync -avzh installers/$(APP)_$(VER)_amd64.deb $(REPO):$(RLOC)$(APP)_amd64.deb ssh $(REPO) chmod a+r $(RLOC)$(APP)_amd64.deb From 4e418916fdd9a0c6a19729e84645f9a289a19776 Mon Sep 17 00:00:00 2001 From: Graham Williams Date: Thu, 25 Sep 2025 11:07:20 +1000 Subject: [PATCH 10/10] Updated template. --- license.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/license.dart b/license.dart index 533a3fd..d170e15 100644 --- a/license.dart +++ b/license.dart @@ -1,6 +1,6 @@ /// SolidPod library to support privacy first data store on Solid Servers /// -// Time-stamp: +// Time-stamp: /// /// Copyright (C) 2025, Software Innovation Institute ANU ///