From b720369dd1803286f5d7d159541dabd6f1e422ca Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Sun, 20 Sep 2026 00:35:52 +1000 Subject: [PATCH 1/2] Fix the issue that the server refuses the request when login --- lib/src/auth/solid_oidc_manager_factory.dart | 45 ++++++++++ lib/src/auth/solid_token_store.dart | 94 ++++++++++++++++++-- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/lib/src/auth/solid_oidc_manager_factory.dart b/lib/src/auth/solid_oidc_manager_factory.dart index f7ccd7f..30fc7c4 100644 --- a/lib/src/auth/solid_oidc_manager_factory.dart +++ b/lib/src/auth/solid_oidc_manager_factory.dart @@ -116,6 +116,51 @@ abstract class SolidOidcManagerFactory { return Future.value(hookRequest); }, + // Report what the token endpoint actually said. + // + // OidcUserManagerBase.tryGetAuthResponse() runs the code exchange + // INSIDE the try block that guards the authorization response, and its + // catch applies the RFC 9207 mix-up defence to any OidcException that + // carries an errorResponse. A token-endpoint error body carries one — + // and never carries `iss`, which is an authorization-response + // parameter — so a plain `invalid_grant` comes back to the caller as + // + // The authorization server advertises + // `authorization_response_iss_parameter_supported` but the + // authorization error response is missing the `iss` parameter + // (RFC 9207 §2.4); refusing as a possible mix-up attack. + // + // which names neither the endpoint that failed nor the reason. Rethrow + // the code-exchange failure without an errorResponse so that catch + // rethrows it untouched, with the server's own error in the message. + // + // Only the authorization_code grant is unwrapped. refresh_token errors + // are left exactly as they are, because oidc_core reads their + // errorResponse to decide whether a refresh failure means re-login. + modifyExecution: (hookRequest, defaultExecution) async { + try { + return await defaultExecution(hookRequest); + } on OidcException catch (e, st) { + final errorResponse = e.errorResponse; + if (errorResponse == null || + hookRequest.request.grantType != + OidcConstants_GrantType.authorizationCode) { + rethrow; + } + final description = errorResponse.errorDescription; + final detail = description == null ? '' : ': $description'; + _log.severe( + 'Token endpoint rejected the code exchange: ' + '${errorResponse.error}$detail', + ); + throw OidcException( + 'The code exchange at ${hookRequest.tokenEndpoint} failed: ' + '${errorResponse.error}$detail', + internalException: e, + internalStackTrace: st, + ); + } + }, ); // Create OIDC hook group and combine any existing hooks with the created diff --git a/lib/src/auth/solid_token_store.dart b/lib/src/auth/solid_token_store.dart index a2d1632..fa167fe 100644 --- a/lib/src/auth/solid_token_store.dart +++ b/lib/src/auth/solid_token_store.dart @@ -28,6 +28,7 @@ library; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:oidc_core/oidc_core.dart'; import 'package:oidc_default_store/oidc_default_store.dart'; /// Builds the store used for everything solid_auth persists between runs. @@ -46,15 +47,96 @@ import 'package:oidc_default_store/oidc_default_store.dart'; /// (RFC 9700 §4.14). /// /// The per-platform options are [OidcDefaultStore]'s own hardened -/// recommendations: an Android-Keystore-backed key on Android, and -/// first-unlock-this-device keychain items (never iCloud-synced) on iOS and -/// macOS. Note that macOS additionally requires the Keychain Sharing -/// entitlement for `flutter_secure_storage` to function at all. +/// recommendations on Android and iOS: an Android-Keystore-backed key, and +/// first-unlock-this-device keychain items (never iCloud-synced). +/// +/// macOS takes those same recommendations with one change: +/// `usesDataProtectionKeychain` is turned OFF. `flutter_secure_storage` +/// defaults it on, and the macOS data protection keychain is reachable only by +/// a process that carries a keychain access group — either declared as the +/// restricted `keychain-access-groups` entitlement, or defaulted from the +/// `com.apple.application-identifier` that an embedded provisioning profile +/// supplies. Developer ID distribution embeds no profile, so a notarized app +/// has neither, and the OS then answers asymmetrically: +/// +/// - `SecItemAdd` fails with `errSecMissingEntitlement` (-34018). The plugin +/// turns that into a `PlatformException`, [OidcDefaultStore] catches it and +/// silently falls back to `package:shared_preferences` — so the secret is +/// written, in the clear, to the app's plist. +/// - `SecItemCopyMatching` returns `errSecItemNotFound` (-25300), which is not +/// an error at all. The plugin returns null, [OidcDefaultStore] reads that as +/// "never stored" and does NOT fall back — so the value just written is +/// invisible. +/// +/// Every secret in this namespace is therefore both leaked to disk and lost on +/// read. For `package:oidc` 4.x that includes the PKCE `code_verifier` (stored +/// under `code_verifier.`), so the code exchange goes out without +/// one and the OP rejects it with `invalid_grant - PKCE verification failed`: +/// login is impossible in a notarized build. +/// +/// Turning the flag off moves macOS to the file-based (login) keychain, which +/// needs no entitlement, works whether or not the app is sandboxed, and is +/// where a Developer ID app's secrets belong. `accessibility` is a data +/// protection attribute and is simply ignored there. +/// +/// 20260920 tonypioneer Diagnosed against the notarized todopod 1.0.46 DMG. -OidcDefaultStore createSolidTokenStore() => OidcDefaultStore( +OidcDefaultStore createSolidTokenStore() => _LoggingStore( secureStorageInstance: const FlutterSecureStorage( aOptions: OidcDefaultStore.recommendedAndroidOptions, iOptions: OidcDefaultStore.recommendedIOSOptions, - mOptions: OidcDefaultStore.recommendedMacOsOptions, + mOptions: macOsKeychainOptions, ), ); + +/// The macOS keychain options used for everything solid_auth persists. +/// +/// [OidcDefaultStore.recommendedMacOsOptions] with the data protection +/// keychain turned off — see [createSolidTokenStore] for why that flag cannot +/// be left on in a Developer ID build. + +const MacOsOptions macOsKeychainOptions = MacOsOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + usesDataProtectionKeychain: false, +); + +// TEMP DIAGNOSTIC - remove. +class _LoggingStore extends OidcDefaultStore { + _LoggingStore({super.secureStorageInstance}); + + @override + Future setMany( + OidcStoreNamespace namespace, { + required Map values, + String? managerId, + }) async { + // ignore: avoid_print + print('STORE set ${namespace.name} keys=${values.keys.toList()} ' + 'values=${namespace == OidcStoreNamespace.state ? values : ''}'); + return super.setMany(namespace, values: values, managerId: managerId); + } + + @override + Future> getMany( + OidcStoreNamespace namespace, { + required Set keys, + String? managerId, + }) async { + final res = await super.getMany(namespace, keys: keys, managerId: managerId); + // ignore: avoid_print + print('STORE get ${namespace.name} keys=$keys -> found=${res.keys.toList()}' + '${namespace == OidcStoreNamespace.state ? ' values=$res' : ''}'); + return res; + } + + @override + Future removeMany( + OidcStoreNamespace namespace, { + required Set keys, + String? managerId, + }) async { + // ignore: avoid_print + print('STORE remove ${namespace.name} keys=$keys'); + return super.removeMany(namespace, keys: keys, managerId: managerId); + } +} From c4867e0ab15bfc369997d139a17023bbd7633183 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Sun, 20 Sep 2026 00:37:17 +1000 Subject: [PATCH 2/2] Lint --- lib/src/auth/solid_token_store.dart | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/src/auth/solid_token_store.dart b/lib/src/auth/solid_token_store.dart index fa167fe..d2ba218 100644 --- a/lib/src/auth/solid_token_store.dart +++ b/lib/src/auth/solid_token_store.dart @@ -111,8 +111,10 @@ class _LoggingStore extends OidcDefaultStore { String? managerId, }) async { // ignore: avoid_print - print('STORE set ${namespace.name} keys=${values.keys.toList()} ' - 'values=${namespace == OidcStoreNamespace.state ? values : ''}'); + print( + 'STORE set ${namespace.name} keys=${values.keys.toList()} ' + 'values=${namespace == OidcStoreNamespace.state ? values : ''}', + ); return super.setMany(namespace, values: values, managerId: managerId); } @@ -122,10 +124,16 @@ class _LoggingStore extends OidcDefaultStore { required Set keys, String? managerId, }) async { - final res = await super.getMany(namespace, keys: keys, managerId: managerId); + final res = await super.getMany( + namespace, + keys: keys, + managerId: managerId, + ); // ignore: avoid_print - print('STORE get ${namespace.name} keys=$keys -> found=${res.keys.toList()}' - '${namespace == OidcStoreNamespace.state ? ' values=$res' : ''}'); + print( + 'STORE get ${namespace.name} keys=$keys -> found=${res.keys.toList()}' + '${namespace == OidcStoreNamespace.state ? ' values=$res' : ''}', + ); return res; }