fix: Adds support for SharedPreferencesAsync - #1164
Conversation
|
I can confirm that this issue exists, as I encountered this as well in a recent project, but didn't have the time to investigate more and come up with a pr. At that time, I solved it by manually providing my own storage implementation using the async shared preferences. |
Pull Request Test Coverage Report for Build 15563534678Details
💛 - Coveralls |
I thought of another solution like this : Code/// A [LocalStorage] implementation that implements SharedPreferences as the
/// storage method.
class SharedPreferencesLocalStorage extends LocalStorage {
late final SharedPreferences _prefs;
SharedPreferencesLocalStorage({required this.persistSessionKey});
final String persistSessionKey;
static const _useWebLocalStorage =
kIsWeb && bool.fromEnvironment("dart.library.js_interop");
@override
Future<void> initialize() async {
if (!_useWebLocalStorage) {
WidgetsFlutterBinding.ensureInitialized();
_prefs = await SharedPreferences.getInstance();
}
}
@override
Future<bool> hasAccessToken() async {
if (_useWebLocalStorage) {
return web.hasAccessToken(persistSessionKey);
}
return _prefs.containsKey(persistSessionKey);
}
@override
Future<String?> accessToken() async {
if (_useWebLocalStorage) {
return web.accessToken(persistSessionKey);
}
return _prefs.getString(persistSessionKey);
}
@override
Future<void> removePersistedSession() async {
if (_useWebLocalStorage) {
web.removePersistedSession(persistSessionKey);
} else {
await _prefs.remove(persistSessionKey);
}
}
@override
Future<void> persistSession(String persistSessionString) {
if (_useWebLocalStorage) {
return web.persistSession(persistSessionKey, persistSessionString);
}
return _prefs.setString(persistSessionKey, persistSessionString);
}
}
/// A [LocalStorage] implementation that implements SharedPreferencesAsync as the
/// storage method.
class SharedPreferencesAsyncLocalStorage extends LocalStorage {
late final SharedPreferencesAsync _prefs;
SharedPreferencesAsyncLocalStorage({required this.persistSessionKey});
final String persistSessionKey;
static const _useWebLocalStorage =
kIsWeb && bool.fromEnvironment("dart.library.js_interop");
@override
Future<void> initialize() async {
if (!_useWebLocalStorage) {
WidgetsFlutterBinding.ensureInitialized();
_prefs = SharedPreferencesAsync();
}
}
@override
Future<bool> hasAccessToken() async {
if (_useWebLocalStorage) {
return web.hasAccessToken(persistSessionKey);
}
return _prefs.containsKey(persistSessionKey);
}
@override
Future<String?> accessToken() async {
if (_useWebLocalStorage) {
return web.accessToken(persistSessionKey);
}
return _prefs.getString(persistSessionKey);
}
@override
Future<void> removePersistedSession() async {
if (_useWebLocalStorage) {
web.removePersistedSession(persistSessionKey);
} else {
await _prefs.remove(persistSessionKey);
}
}
@override
Future<void> persistSession(String persistSessionString) {
if (_useWebLocalStorage) {
return web.persistSession(persistSessionKey, persistSessionString);
}
return _prefs.setString(persistSessionKey, persistSessionString);
}
}where the dev has to opt-in for I didn't choose this implementation in my PR because the two classes are pretty much the same except for shared prefs init. |
|
I think CI test fails because with Flutter 3.19 the resolvable |
|
@Vinzent03 , Can you exactly tell me what is the solution you have done for this solution |
the refresh_token_already_used error is coming, though supabase is handling auto refresh - This comes when user again opens the app (Cold start) after the token expiry time, then instead of emitting token refreshed state, the error is coming! |
|
If we want to provide a Either way @romaingyh I prefer the two distinct classes implementation. |
Broken in what way? |
Done, PR has now two classes : |
|
@dshukertjr The way it's described in the pr description. Writes to |
|
@romaingyh I know this blows up the scope of this PR, but I'm okay bumping the minimum Flutter/ Dart version to 3.220/ 3.4.0 and to just use |
0470383 to
ccbd41f
Compare
|
@dshukertjr I bumped flutter/dart to 3.22/3.4.0 and replaced SharedPreferences with SharedPreferencesAsync. I also added the token migration at init but not tested |
|
But using the new async variant by default causes existing usage in the user's code of the old variant to break, doesn't it? This is a heavy breaking change. |
|
Yes as Async prefs overwrite the legacy prefs (I'm sure on windows but not for other platforms) I think there is a risk of user losing it's preferences if he upgrade supabase but still use legacy prefs in his code |
ccbd41f to
fda1304
Compare
|
I rebased |
|
@romaingyh do you know when your fix will be available in the public release of Supabase? Thank you. I updated my app today and broke this Windows sign in as described here. |
Sorry no I'm not an officiel maintainer of this repo. We're currently waiting for the main maintainer to publish new releases. Here is what I'm doing for now. In pubspec : supabase_flutter:
git:
url: https://github.com/romaingyh/supabase-flutter.git
ref: shared_prefs_async
path: packages/supabase_flutterand use this in supabase initialization auth options : localStorage: SharedPreferencesLocalStorage(
persistSessionKey: "sb-${Uri.parse(url).host.split(".").first}-auth-token",
),You can also override supabase to have the last fixes : # TODO: Remove when supabase > 2.8.0 published on pub.dev, including https://github.com/supabase/supabase-flutter/pull/1196
supabase:
git:
url: https://github.com/romaingyh/supabase-flutter.git
path: packages/supabase |
…: SharedPreferencesLocalStorage & SharedPreferencesAsyncLocalStorage
…to new SharedPreferencesAsync
fda1304 to
712a2ea
Compare
There was a problem hiding this comment.
Pull request overview
This PR migrates the SharedPreferencesLocalStorage class from the legacy SharedPreferences API to the newer SharedPreferencesAsync API to address compatibility issues on platforms like Windows where the two storage mechanisms are incompatible. The change prevents users from being logged out when using SharedPreferencesAsync in their application code.
Changes:
- Updated
shared_preferencesdependency from^2.0.0to^2.3.0 - Migrated
SharedPreferencesLocalStorageto useSharedPreferencesAsyncinstead of the legacySharedPreferences - Added automatic migration logic to transfer existing sessions from legacy to async storage
- Bumped minimum SDK version from 3.3.0 to 3.4.0 and Flutter from 3.19.0 to 3.22.0
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| packages/supabase_flutter/pubspec.yaml | Updates dependency constraints for SDK, Flutter, and shared_preferences package |
| packages/supabase_flutter/lib/src/local_storage.dart | Migrates SharedPreferencesLocalStorage to use SharedPreferencesAsync and adds migration logic |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| sdk: '>=3.4.0 <4.0.0' | ||
| flutter: '>=3.22.0' |
There was a problem hiding this comment.
The SDK and Flutter version bumps are significant breaking changes (from 3.3.0 to 3.4.0 and 3.19.0 to 3.22.0). These version requirements should be justified - are they necessary for SharedPreferencesAsync support? If not, consider using the original minimum versions to avoid unnecessarily restricting the user base.
| sdk: '>=3.4.0 <4.0.0' | |
| flutter: '>=3.22.0' | |
| sdk: '>=3.3.0 <4.0.0' | |
| flutter: '>=3.19.0' |
| final legacyPrefs = await SharedPreferences.getInstance(); | ||
|
|
||
| if (legacyPrefs.containsKey(persistSessionKey)) { | ||
| final accessToken = legacyPrefs.getString(persistSessionKey); | ||
|
|
||
| if (accessToken != null) { | ||
| await legacyPrefs.remove(persistSessionKey); | ||
| await _prefs.setString(persistSessionKey, accessToken); | ||
| } |
There was a problem hiding this comment.
The migration logic should handle errors gracefully. If the migration fails (e.g., due to storage access issues), the error could leave the session in an inconsistent state or prevent initialization. Consider wrapping the migration in a try-catch block and logging any errors, allowing initialization to proceed even if migration fails.
| final legacyPrefs = await SharedPreferences.getInstance(); | |
| if (legacyPrefs.containsKey(persistSessionKey)) { | |
| final accessToken = legacyPrefs.getString(persistSessionKey); | |
| if (accessToken != null) { | |
| await legacyPrefs.remove(persistSessionKey); | |
| await _prefs.setString(persistSessionKey, accessToken); | |
| } | |
| try { | |
| final legacyPrefs = await SharedPreferences.getInstance(); | |
| if (legacyPrefs.containsKey(persistSessionKey)) { | |
| final accessToken = legacyPrefs.getString(persistSessionKey); | |
| if (accessToken != null) { | |
| await legacyPrefs.remove(persistSessionKey); | |
| await _prefs.setString(persistSessionKey, accessToken); | |
| } | |
| } | |
| } catch (e, stackTrace) { | |
| debugPrint( | |
| 'Error while migrating access token for key "$persistSessionKey": $e'); | |
| debugPrint(stackTrace.toString()); |
| } | ||
| } | ||
|
|
||
| Future<void> _maybeMigrateAccessToken() async { |
There was a problem hiding this comment.
The new migration logic in _maybeMigrateAccessToken lacks test coverage. Since the existing tests for SharedPreferencesLocalStorage use setMockInitialValues which may not properly test the migration from legacy SharedPreferences to SharedPreferencesAsync, add tests to verify: 1) successful migration when legacy data exists, 2) no-op when legacy data doesn't exist, and 3) proper cleanup of legacy storage after migration.
|
|
||
| if (accessToken != null) { | ||
| await legacyPrefs.remove(persistSessionKey); | ||
| await _prefs.setString(persistSessionKey, accessToken); |
There was a problem hiding this comment.
The variable accessToken appears to hold a Supabase auth/session token and is being stored in cleartext using SharedPreferencesAsync, which typically persists data in an unencrypted file on the device. Because this is a bearer token, any attacker or malicious app able to read the underlying shared preferences storage (e.g., via device compromise, backup extraction, or sandbox escape) can reuse this token to impersonate the user against Supabase, leading to account compromise. Severity: HIGH. Confidence: 8
|
This pull request has been inactive for 90 days. If you plan to continue working on this PR, please leave a comment to keep it open. |
|
This pull request was automatically closed due to inactivity. If you'd like to continue this work, please reopen the PR or create a new one. |
|
@romaingyh thanks for your work on this, I missed that it was auto closed. |
…nc (#1680) ## What kind of change does this PR introduce? Bug fix, breaking change. ## What is the current behavior? `SharedPreferencesLocalStorage` and `SharedPreferencesGotrueAsyncStorage`, the storage implementations `Supabase.initialize` uses by default, write through the legacy `SharedPreferences` API. On Windows and Linux both APIs rewrite the same `shared_preferences.json` in full from their own in-memory cache, so an app that has migrated its own code to `SharedPreferencesAsync` has its session dropped and the user is signed out on the next launch. Closes #1276, and supersedes #1164. ## What is the new behavior? Both implementations use `SharedPreferencesAsync`. The minimum Flutter version is already well past the 3.22 that API needs, so nothing had to be bumped. `SharedPreferencesLocalStorage.initialize()` moves a session written by v2 over to the new store, so existing users stay signed in: - It runs once. A `<persistSessionKey>-legacy-migrated` key in the new store records that it happened, so the legacy store is read at most once and a signed-out user is never signed back in. - Deleting the legacy entry is not enough on its own to get that guarantee: on Windows and Linux a later write through either API can bring a deleted entry back. The delete still happens, but only so a stale token does not lie around. - A session already in the new store wins, so a stale legacy one never overwrites it. - The legacy store is `reload()`ed first, since the instance is shared with the app and may have been loaded before the session was last written. On web nothing changes: the session still goes into `window.localStorage` under the same key, as it did before. `SharedPreferencesGotrueAsyncStorage` no longer needs its initialization `Completer`, since `SharedPreferencesAsync` has no asynchronous setup. It is created on first use so that constructing the storage does not require the bindings to be initialized yet. The pending PKCE code verifier is not migrated. It is valid for the length of one sign-in round trip, and the failure mode of losing it is a retryable sign-in. ## Superseding #1164 That PR made the same core change. Here is where each point from its review thread landed: | Raised there | Here | | --- | --- | | Windows loses the session when the app uses `SharedPreferencesAsync` | Fixed, both default storages moved to the async API | | Transfer the existing token to the new store on initialization | Done, and made one-time, order-safe and precedence-aware as described above | | Needs Flutter 3.22 / Dart 3.4 for `SharedPreferencesAsync` | Already satisfied on `main`: Flutter 3.35, Dart 3.9, `shared_preferences: ^2.5.5` | | Prefer two storage classes over a `useSharedPreferencesAsync` flag | Neither. v3 uses the async API unconditionally, which is what was asked for on that thread instead | | A heavy breaking change for apps that still use the legacy API themselves | `MIGRATION.md` now states per platform what mixing costs, including that a session write can drop the app's own legacy preferences, and ships a `LocalStorage` to copy for staying on the legacy store | | `SharedPreferencesGotrueAsyncStorage` left on the legacy API | Moved over too, so the PKCE code verifier does not keep the mixing problem alive | | No test coverage (0 of 8 changed lines) | Six migration tests, listed below | One comment on that thread reported `refresh_token_already_used` on a cold start after token expiry. That is an auth-refresh problem rather than a storage one, so it is deliberately out of scope here. ## Additional context `MIGRATION.md` documents the change, including a `LocalStorage` implementation to copy for anyone who wants to keep the session in the legacy store for now. Tests cover the migration: a legacy session is moved over, the legacy entry is gone afterwards, an existing session in the new store wins, a session that was signed out of is not restored on the next launch, the same holds when both stores had a session, and a legacy entry written back after the migration is ignored. They live in a non-web test file, since on web there is nothing to migrate. The other test files now mock both shared_preferences APIs through a `mockSharedPreferences` helper. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Mobile session persistence now uses the modern asynchronous preferences API. * Existing sessions migrate automatically from legacy storage. * **Bug Fixes** * Prevented session loss and storage conflicts during migration. * Signed-out sessions are not restored. * Preserved browser `localStorage` behavior on web. * **Documentation** * Added migration guidance and updated custom storage documentation, including compatibility considerations for legacy storage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
What kind of change does this PR introduce?
It's both a bug fix and feature.
9 months ago with version 2.3.0,
shared_preferencespackage addedSharedPreferencesAsyncandSharedPreferencesWithCacheto replace the legacy (and deprecated in future)SharedPreferences. See hereSupabase flutter uses by default the legacy one with
SharedPreferencesLocalStorage.Problem is that on some platform like Windows, the legacy one and new one are not compatible. If the developer made the migration to
SharedPreferencesAsyncthen the supabase's local storage is broken.Example :
I'm using
SharedPreferencesAsyncin my app. After sign in, thesb-x-auth-tokenis saved insaved-preferences.jsonby supabase'sSharedPreferences. After that, any call to my app'sSharedPreferencesAsyncwill overwrite the token and user have to sign in on next app launch. In practice it can lead to sign in every time he opens the app if you use shared preferences frequently in your code.What is the new behavior?
I added a boolean flag
useSharedPreferencesAsynctoSharedPreferencesLocalStorage. There wasn't very much changes as every methods are already async in LocalStorage interface.If you prefer two distinct classes
SharedPreferencesLocalStorageandSharedPreferencesAsyncLocalStorageI can do this