diff --git a/AGENTS.md b/AGENTS.md index cfe3e1d70..4993a06dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ melos version - **GoTrueClient** manages sessions, tokens, and JWT validation - Emits auth state changes via `Stream` -- **supabase_flutter** adds session persistence via `SharedPreferences` (mobile) or browser localStorage (web) +- **supabase_flutter** adds session persistence via `SharedPreferencesAsync` (mobile) or browser localStorage (web) - Deep link handling for OAuth callbacks (detects `?code=` for PKCE or `#access_token=` for implicit flow) - Auth tokens are automatically injected into all HTTP requests via `AuthHttpClient` - Realtime client receives token updates when auth state changes @@ -169,7 +169,7 @@ import './local_storage_stub.dart' ``` This enables: -- Native mobile (iOS/Android): SharedPreferences for persistence +- Native mobile (iOS/Android): SharedPreferencesAsync for persistence - Web: Browser localStorage - Single codebase for all platforms diff --git a/MIGRATION.md b/MIGRATION.md index 097a7c985..921c19102 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -418,3 +418,146 @@ the server to restore both. The `ApiVersions` class, and its `ApiVersions.v20240101` field, are removed along with it. Nothing replaces them; they only existed to drive the comparison above. + +### The session is persisted with `SharedPreferencesAsync` + +`SharedPreferencesLocalStorage` and `SharedPreferencesGotrueAsyncStorage`, the storage +implementations `Supabase.initialize` uses by default, wrote through the legacy +[`SharedPreferences`](https://pub.dev/packages/shared_preferences#sharedpreferences-vs-sharedpreferencesasync-vs-sharedpreferenceswithcache) +API. They now use `SharedPreferencesAsync`. On web the session still goes into +`window.localStorage` under the same key as before, so nothing changes there. + +The two APIs do not share a store on every platform, and on the ones where they do the legacy API +prefixes its keys, so a session written by v2 is invisible to the new one. `initialize()` therefore +moves an existing session over to `SharedPreferencesAsync` the first time it runs and deletes the +legacy entry, so your users stay signed in. No code change is needed for this, and there is nothing +to migrate if you already pass your own `LocalStorage`. + +What this does mean is that the SDK no longer holds up its end of a mixed setup, and mixing is +worse than it first looks. How the two APIs relate depends on the platform: + +| Platform | Relationship between the two APIs | +| --- | --- | +| Windows, Linux | One `shared_preferences.json`, rewritten in full from each API's own cache, so a write through either one can drop what the other wrote | +| Android | Separate stores, `SharedPreferences` against DataStore, so a value written through one is invisible to the other | +| iOS, macOS, web | One store, but the legacy API prefixes its keys with `flutter.`, so a value written through one is invisible to the other | + +Only the first row loses data, and it loses it in both directions. That is what made sessions go +missing in v2, and from v3 on the same collision runs the other way: a session write by the SDK can +drop preferences your own code wrote through the legacy API. So if your code still calls +`SharedPreferences.getInstance()`, this is the moment to +[migrate it to `SharedPreferencesAsync`](https://pub.dev/packages/shared_preferences#migrating-from-sharedpreferences-to-sharedpreferencesasync-or-sharedpreferenceswithcache) +as well. The snippet below is the way out if you cannot do that yet. + +If you would rather keep the session in the legacy store for now, pass a `LocalStorage` that reads +and writes it. Supplying your own storage is also where the session key comes in: `initialize()` +derives it from your project URL for the default storage, so you only name the key when you +construct a `LocalStorage` yourself, and `defaultPersistSessionKey` hands you the same one. + +```dart +class LegacySharedPreferencesLocalStorage extends LocalStorage { + LegacySharedPreferencesLocalStorage({required this.persistSessionKey}); + + final String persistSessionKey; + + late final SharedPreferences _preferences; + + @override + Future initialize() async { + _preferences = await SharedPreferences.getInstance(); + } + + @override + Future hasAccessToken() async => + _preferences.containsKey(persistSessionKey); + + @override + Future accessToken() async => + _preferences.getString(persistSessionKey); + + @override + Future removePersistedSession() => + _preferences.remove(persistSessionKey); + + @override + Future persistSession(String persistSessionString) => + _preferences.setString(persistSessionKey, persistSessionString); +} + +await Supabase.initialize( + url: url, + publishableKey: publishableKey, + authOptions: FlutterAuthClientOptions( + localStorage: LegacySharedPreferencesLocalStorage( + persistSessionKey: defaultPersistSessionKey(url), + ), + ), +); +``` + +Widget tests that call `Supabase.initialize` need one more line of setup. +`SharedPreferences.setMockInitialValues()` only stands in for the legacy API, so on its own the new +storage throws `StateError: The SharedPreferencesAsyncPlatform instance must be set.` Register an +in-memory async store next to it: + +```dart +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +setUp(() { + SharedPreferences.setMockInitialValues({}); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); +}); +``` + +`shared_preferences_platform_interface` needs to be a `dev_dependency` for that import. Passing +`FlutterAuthClientOptions(localStorage: const EmptyLocalStorage())` instead skips storage in tests +altogether. + +### `supabasePersistSessionKey` is gone + +The constant existed for the v1 to v2 migration from Hive, which v3 no longer carries, and the SDK +itself never read it. The session is stored under the key you pass to `LocalStorage`, which for the +default storage is `sb--auth-token`. + +The `LocalStorage` examples in the README used the constant as their storage key, so if you copied +one of those, take the key as a parameter instead: + +```dart +// Before +class MySecureStorage extends LocalStorage { + @override + Future accessToken() => storage.read(key: supabasePersistSessionKey); + // ... +} + +// After +class MySecureStorage extends LocalStorage { + MySecureStorage({required this.persistSessionKey}); + + final String persistSessionKey; + + @override + Future accessToken() => storage.read(key: persistSessionKey); + // ... +} + +await Supabase.initialize( + url: url, + publishableKey: publishableKey, + authOptions: FlutterAuthClientOptions( + localStorage: MySecureStorage( + persistSessionKey: defaultPersistSessionKey(url), + ), + ), +); +``` + +Passing the key you already store under keeps your users signed in; switching to a different key +signs them out once. To keep the old value, pass `'SUPABASE_PERSIST_SESSION_KEY'`, which is what the +constant held. + +The `MigrationLocalStorage` and `HiveLocalStorage` snippets that migrated a v1 session out of +[hive](https://pub.dev/packages/hive) are gone from the README along with it. If you are still on +v1, upgrade to v2 first and let it migrate the session, then move to v3. diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 9a015dab9..39f1e4c87 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -1512,8 +1512,7 @@ class GoTrueClient { void _mayStartBroadcastChannel() { if (const bool.fromEnvironment('dart.library.js_interop')) { // Used by the js library as well - final broadcastKey = - "sb-${Uri.parse(_url).host.split(".").first}-auth-token"; + final broadcastKey = defaultPersistSessionKey(_url); assert( _broadcastChannel == null, diff --git a/packages/gotrue/test/admin_list_users_test.dart b/packages/gotrue/test/admin_list_users_test.dart index 7db2b53ad..9883529be 100644 --- a/packages/gotrue/test/admin_list_users_test.dart +++ b/packages/gotrue/test/admin_list_users_test.dart @@ -28,7 +28,10 @@ class ListUsersMockClient extends BaseClient { utf8.encode( jsonEncode({ 'users': [ - {'id': '2fa5b8b0-4f1a-4a5c-9d47-1cf3dbb9f7e1'}, + { + 'id': '2fa5b8b0-4f1a-4a5c-9d47-1cf3dbb9f7e1', + 'created_at': '2024-01-01T00:00:00Z', + }, ], 'aud': ?audience, }), diff --git a/packages/supabase_common/lib/src/persist_session_key.dart b/packages/supabase_common/lib/src/persist_session_key.dart new file mode 100644 index 000000000..78bb322e9 --- /dev/null +++ b/packages/supabase_common/lib/src/persist_session_key.dart @@ -0,0 +1,11 @@ +/// The key the user session is persisted under for the project at +/// [supabaseUrl]. +/// +/// This is the key `Supabase.initialize` passes to the default `LocalStorage`, +/// so pass it to your own `LocalStorage` implementation to keep reading and +/// writing the session the SDK already persisted. +/// +/// The other Supabase client libraries derive the key the same way, so a +/// session written by one of them is found by the others. +String defaultPersistSessionKey(String supabaseUrl) => + 'sb-${Uri.parse(supabaseUrl).host.split('.').first}-auth-token'; diff --git a/packages/supabase_common/lib/supabase_common.dart b/packages/supabase_common/lib/supabase_common.dart index 63457ab4f..1892bcdf0 100644 --- a/packages/supabase_common/lib/supabase_common.dart +++ b/packages/supabase_common/lib/supabase_common.dart @@ -10,6 +10,7 @@ export 'src/client_info.dart'; export 'src/fetch_options.dart'; export 'src/http_method.dart'; export 'src/http_status.dart'; +export 'src/persist_session_key.dart'; export 'src/pkce.dart'; export 'src/platform/platform_info.dart'; export 'src/replay_subject.dart'; diff --git a/packages/supabase_common/test/persist_session_key_test.dart b/packages/supabase_common/test/persist_session_key_test.dart new file mode 100644 index 000000000..63ee323b8 --- /dev/null +++ b/packages/supabase_common/test/persist_session_key_test.dart @@ -0,0 +1,27 @@ +import 'package:supabase_common/supabase_common.dart'; +import 'package:test/test.dart'; + +void main() { + group('defaultPersistSessionKey', () { + test('derives the key from the project ref', () { + expect( + defaultPersistSessionKey('https://abcdefghijklmnop.supabase.co'), + 'sb-abcdefghijklmnop-auth-token', + ); + }); + + test('ignores the port and the path', () { + expect( + defaultPersistSessionKey('http://localhost:54321/rest/v1'), + 'sb-localhost-auth-token', + ); + }); + + test('handles a custom domain', () { + expect( + defaultPersistSessionKey('https://auth.example.com'), + 'sb-auth-auth-token', + ); + }); + }); +} diff --git a/packages/supabase_flutter/README.md b/packages/supabase_flutter/README.md index 9afe2a233..8ede9e767 100644 --- a/packages/supabase_flutter/README.md +++ b/packages/supabase_flutter/README.md @@ -504,13 +504,18 @@ https://supabase.io/docs/guides/auth#third-party-logins ## Custom LocalStorage -As default, `supabase_flutter` uses [`Shared preferences`](https://pub.dev/packages/shared_preferences) to persist the user session. +By default, `supabase_flutter` uses the `SharedPreferencesAsync` API of [`shared_preferences`](https://pub.dev/packages/shared_preferences) to persist the user session. If your own code still uses the legacy `SharedPreferences` API, [migrate it to `SharedPreferencesAsync`](https://pub.dev/packages/shared_preferences#migrating-from-sharedpreferences-to-sharedpreferencesasync-or-sharedpreferenceswithcache): on Windows and Linux both APIs rewrite the same file from their own cache, so a write through one drops what the other wrote, and a mixed setup can lose your preferences as well as the session. However, you can use any other methods by creating a `LocalStorage` implementation. For example, we can use [`flutter_secure_storage`](https://pub.dev/packages/flutter_secure_storage) plugin to store the user session in a secure storage. +The key the session is stored under is derived from your project URL by `Supabase.initialize`. You only pass it yourself when you construct a `LocalStorage`, as below, and `defaultPersistSessionKey` gives you the same key the default storage uses. + ```dart // Define the custom LocalStorage implementation class MySecureStorage extends LocalStorage { + MySecureStorage({required this.persistSessionKey}); + + final String persistSessionKey; final storage = FlutterSecureStorage(); @@ -519,22 +524,22 @@ class MySecureStorage extends LocalStorage { @override Future accessToken() async { - return storage.read(key: supabasePersistSessionKey); + return storage.read(key: persistSessionKey); } @override Future hasAccessToken() async { - return storage.containsKey(key: supabasePersistSessionKey); + return storage.containsKey(key: persistSessionKey); } @override Future persistSession(String persistSessionString) async { - return storage.write(key: supabasePersistSessionKey, value: persistSessionString); + return storage.write(key: persistSessionKey, value: persistSessionString); } @override Future removePersistedSession() async { - return storage.delete(key: supabasePersistSessionKey); + return storage.delete(key: persistSessionKey); } } @@ -542,7 +547,9 @@ class MySecureStorage extends LocalStorage { Supabase.initialize( ... authOptions: FlutterAuthClientOptions( - localStorage: MySecureStorage(), + localStorage: MySecureStorage( + persistSessionKey: defaultPersistSessionKey(supabaseUrl), + ), ), ); ``` @@ -558,160 +565,6 @@ Supabase.initialize( ); ``` -### Persisting the user session from supabase_flutter v1 - -supabase_flutter v1 used hive to persist the user session. In the current version of supabase_flutter it uses shared_preferences. If you are updating your app from v1 to v2, you can use the following custom `LocalStorage` implementation to automatically migrate the user session from [hive](https://pub.dev/packages/hive) to [shared_preferences](https://pub.dev/packages/shared_preferences). - -```dart -const _hiveBoxName = 'supabase_authentication'; - -class MigrationLocalStorage extends LocalStorage { - final SharedPreferencesLocalStorage sharedPreferencesLocalStorage; - late final HiveLocalStorage hiveLocalStorage; - - MigrationLocalStorage({required String persistSessionKey}) - : sharedPreferencesLocalStorage = - SharedPreferencesLocalStorage(persistSessionKey: persistSessionKey); - - @override - Future initialize() async { - await Hive.initFlutter('auth'); - hiveLocalStorage = const HiveLocalStorage(); - await sharedPreferencesLocalStorage.initialize(); - try { - await migrate(); - } on TimeoutException { - // Ignore TimeoutException thrown by Hive methods - // https://github.com/supabase/supabase-flutter/issues/794 - } - } - - @visibleForTesting - Future migrate() async { - // Migrate from Hive to SharedPreferences - if (await Hive.boxExists(_hiveBoxName)) { - await hiveLocalStorage.initialize(); - - final hasHive = await hiveLocalStorage.hasAccessToken(); - if (hasHive) { - final accessToken = await hiveLocalStorage.accessToken(); - final session = - Session.fromJson(jsonDecode(accessToken!)['currentSession']); - if (session == null) { - return; - } - await sharedPreferencesLocalStorage - .persistSession(jsonEncode(session.toJson())); - await hiveLocalStorage.removePersistedSession(); - } - if (Hive.box(_hiveBoxName).isEmpty) { - final boxPath = Hive.box(_hiveBoxName).path; - await Hive.deleteBoxFromDisk(_hiveBoxName); - - //Delete `auth` folder if it's empty - if (!kIsWeb && boxPath != null) { - final boxDir = File(boxPath).parent; - final dirIsEmpty = await boxDir.list().length == 0; - if (dirIsEmpty) { - await boxDir.delete(); - } - } - } - } - } - - @override - Future accessToken() { - return sharedPreferencesLocalStorage.accessToken(); - } - - @override - Future hasAccessToken() { - return sharedPreferencesLocalStorage.hasAccessToken(); - } - - @override - Future persistSession(String persistSessionString) { - return sharedPreferencesLocalStorage.persistSession(persistSessionString); - } - - @override - Future removePersistedSession() { - return sharedPreferencesLocalStorage.removePersistedSession(); - } -} - -/// A [LocalStorage] implementation that implements Hive as the -/// storage method. -class HiveLocalStorage extends LocalStorage { - /// Creates a LocalStorage instance that implements the Hive Database - const HiveLocalStorage(); - - /// The encryption key used by Hive. If null, the box is not encrypted - /// - /// This value should not be redefined in runtime, otherwise the user may - /// not be fetched correctly - /// - /// See also: - /// - /// * - static String? encryptionKey; - - @override - Future initialize() async { - HiveCipher? encryptionCipher; - if (encryptionKey != null) { - encryptionCipher = HiveAesCipher(base64Url.decode(encryptionKey!)); - } - await Hive.initFlutter('auth'); - await Hive.openBox(_hiveBoxName, encryptionCipher: encryptionCipher) - .timeout(const Duration(seconds: 1)); - } - - @override - Future hasAccessToken() { - return Future.value( - Hive.box(_hiveBoxName).containsKey( - supabasePersistSessionKey, - ), - ); - } - - @override - Future accessToken() { - return Future.value( - Hive.box(_hiveBoxName).get(supabasePersistSessionKey) as String?, - ); - } - - @override - Future removePersistedSession() { - return Hive.box(_hiveBoxName).delete(supabasePersistSessionKey); - } - - @override - Future persistSession(String persistSessionString) { - // Flush after X amount of writes - return Hive.box(_hiveBoxName) - .put(supabasePersistSessionKey, persistSessionString); - } -} -``` - -You can then initialize Supabase with `MigrationLocalStorage` and it will automatically migrate the session from Hive to SharedPreferences. - -```dart -Supabase.initialize( - // ... - authOptions: FlutterAuthClientOptions( - localStorage: const MigrationLocalStorage( - persistSessionKey: - "sb-${Uri.parse(url).host.split(".").first}-auth-token", - ), - ), -); -``` - ## Logging All Supabase packages use the [logging](https://pub.dev/packages/logging) package to log information. Each sub-package has its own logger instance. You can listen to logs and set custom log levels for each logger. @@ -746,8 +599,8 @@ supabaseLogger.onRecord.listen((record) { ## Migrating Guide -You can find the migration guide to migrate from v1 to v2 here: -https://supabase.com/docs/reference/dart/upgrade-guide +The breaking changes of each major version, and what to do about them, are documented in +[MIGRATION.md](https://github.com/supabase/supabase-flutter/blob/main/MIGRATION.md). ## Contributing diff --git a/packages/supabase_flutter/lib/src/local_storage.dart b/packages/supabase_flutter/lib/src/local_storage.dart index 060f0e6fe..ab3c64810 100644 --- a/packages/supabase_flutter/lib/src/local_storage.dart +++ b/packages/supabase_flutter/lib/src/local_storage.dart @@ -1,7 +1,6 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -9,8 +8,7 @@ import './local_storage_stub.dart' if (dart.library.js_interop) './local_storage_web.dart' as web; -/// Only used for migration from Hive to SharedPreferences. Not actually in use. -const supabasePersistSessionKey = 'SUPABASE_PERSIST_SESSION_KEY'; +final _log = Logger('supabase.supabase_flutter'); /// LocalStorage is used to persist the user session in the device. /// @@ -18,8 +16,8 @@ const supabasePersistSessionKey = 'SUPABASE_PERSIST_SESSION_KEY'; /// /// * [SupabaseAuth], the instance used to manage authentication /// * [EmptyLocalStorage], used to disable session persistence -/// * [SharedPreferencesLocalStorage], that implements SharedPreferences as -/// storage method +/// * [SharedPreferencesLocalStorage], that implements SharedPreferencesAsync +/// as storage method abstract class LocalStorage { const LocalStorage(); @@ -61,10 +59,14 @@ class EmptyLocalStorage extends LocalStorage { Future persistSession(persistSessionString) async {} } -/// A [LocalStorage] implementation that implements SharedPreferences as the -/// storage method. +/// A [LocalStorage] implementation that implements [SharedPreferencesAsync] as +/// the storage method. +/// +/// A session persisted by supabase_flutter v2, which used the legacy +/// [SharedPreferences] API, is moved over to [SharedPreferencesAsync] on +/// [initialize]. class SharedPreferencesLocalStorage extends LocalStorage { - late final SharedPreferences _preferences; + late final SharedPreferencesAsync _preferences; SharedPreferencesLocalStorage({required this.persistSessionKey}); @@ -76,7 +78,64 @@ class SharedPreferencesLocalStorage extends LocalStorage { Future initialize() async { if (!_useWebLocalStorage) { WidgetsFlutterBinding.ensureInitialized(); - _preferences = await SharedPreferences.getInstance(); + _preferences = SharedPreferencesAsync(); + await _migrateLegacySession(); + } + } + + /// Records that [_migrateLegacySession] has run, so that it runs once. + String get _legacyMigrationKey => '$persistSessionKey-legacy-migrated'; + + /// Moves a session written by the legacy [SharedPreferences] API over to + /// [SharedPreferencesAsync]. + /// + /// The two APIs do not share a store on every platform, and on the platforms + /// where they do the legacy one prefixes its keys, so a session written by + /// supabase_flutter v2 is invisible to [SharedPreferencesAsync]. + /// + /// Deleting the legacy entry is not enough to make this a one-time move. On + /// the platforms where both APIs rewrite one file from their own cache, a + /// later write through either API can bring the deleted entry back, and a + /// resurrected session would sign a user in again after they signed out. + /// [_legacyMigrationKey] is what makes the move happen once, and the delete + /// is only there to keep a stale token from lying around. + /// + /// An entry that comes back afterwards is left where it is. It is never read + /// again, and deleting it would mean a legacy write on every launch: that + /// write rewrites the whole store even when the key is absent, which on those + /// same platforms is what drops values the other API wrote. + /// + /// A failure to read the legacy store costs the user a sign-in, so it is + /// logged rather than thrown: throwing here would take `Supabase.initialize` + /// with it and leave the app unable to start over a session it may not even + /// have. + Future _migrateLegacySession() async { + final stored = await _preferences.getAll( + allowList: {persistSessionKey, _legacyMigrationKey}, + ); + if (stored.containsKey(_legacyMigrationKey)) { + return; + } + try { + final legacyPreferences = await SharedPreferences.getInstance(); + final legacySession = legacyPreferences.getString(persistSessionKey); + // The new store is written first, so that an interruption before the + // legacy entry is gone leaves the session in one store or the other + // rather than in neither. + if (legacySession != null && !stored.containsKey(persistSessionKey)) { + await _preferences.setString(persistSessionKey, legacySession); + } + await _preferences.setBool(_legacyMigrationKey, true); + if (legacySession != null) { + // Picks up what was just written through the other API. Without it the + // legacy cache is a pre-migration snapshot, and on the platforms where + // the two share a file the next legacy write by the app would rewrite + // the file from that snapshot, taking the migrated session with it. + await legacyPreferences.reload(); + await legacyPreferences.remove(persistSessionKey); + } + } catch (error, stackTrace) { + _log.warning('Could not migrate the session', error, stackTrace); } } @@ -118,38 +177,45 @@ class SharedPreferencesLocalStorage extends LocalStorage { /// local storage to store pkce flow code verifier. class SharedPreferencesGotrueAsyncStorage extends GotrueAsyncStorage { SharedPreferencesGotrueAsyncStorage() { - unawaited(_initialize()); + WidgetsFlutterBinding.ensureInitialized(); } - final Completer _initializationCompleter = Completer(); + /// Created on first use, since the plugin it talks to is only registered + /// once the bindings are initialized. + late final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); - late final SharedPreferences _preferences; + @override + Future getItem({required String key}) async { + return await _preferences.getString(key) ?? await _legacyItem(key); + } - Future _initialize() async { + /// Moves a value written by the legacy [SharedPreferences] API over to + /// [SharedPreferencesAsync]. + /// + /// A code verifier outlives the launch that wrote it: a magic link or a + /// password reset can be opened long after the app updated, and the flow it + /// belongs to cannot be completed without the verifier that started it. + Future _legacyItem(String key) async { try { - WidgetsFlutterBinding.ensureInitialized(); - _preferences = await SharedPreferences.getInstance(); - _initializationCompleter.complete(); + final legacyPreferences = await SharedPreferences.getInstance(); + final value = legacyPreferences.getString(key); + if (value == null) { + return null; + } + await _preferences.setString(key, value); + await legacyPreferences.reload(); + await legacyPreferences.remove(key); + return value; } catch (error, stackTrace) { - _initializationCompleter.completeError(error, stackTrace); + _log.warning('Could not read the legacy store', error, stackTrace); + return null; } } @override - Future getItem({required String key}) async { - await _initializationCompleter.future; - return _preferences.getString(key); - } + Future removeItem({required String key}) => _preferences.remove(key); @override - Future removeItem({required String key}) async { - await _initializationCompleter.future; - await _preferences.remove(key); - } - - @override - Future setItem({required String key, required String value}) async { - await _initializationCompleter.future; - await _preferences.setString(key, value); - } + Future setItem({required String key, required String value}) => + _preferences.setString(key, value); } diff --git a/packages/supabase_flutter/lib/src/supabase.dart b/packages/supabase_flutter/lib/src/supabase.dart index f73bb49b2..57bee95fd 100644 --- a/packages/supabase_flutter/lib/src/supabase.dart +++ b/packages/supabase_flutter/lib/src/supabase.dart @@ -127,8 +127,7 @@ class Supabase { authOptions = authOptions.copyWith( localStorage: authOptions.persistSession ? SharedPreferencesLocalStorage( - persistSessionKey: - "sb-${Uri.parse(url).host.split(".").first}-auth-token", + persistSessionKey: defaultPersistSessionKey(url), ) : const EmptyLocalStorage(), ); diff --git a/packages/supabase_flutter/lib/supabase_flutter.dart b/packages/supabase_flutter/lib/supabase_flutter.dart index 1a9a5fabd..f85d84ea2 100644 --- a/packages/supabase_flutter/lib/supabase_flutter.dart +++ b/packages/supabase_flutter/lib/supabase_flutter.dart @@ -2,6 +2,8 @@ library; export 'package:supabase/supabase.dart'; +export 'package:supabase_common/supabase_common.dart' + show defaultPersistSessionKey; export 'package:url_launcher/url_launcher.dart' show LaunchMode; export 'src/flutter_go_true_client_options.dart'; diff --git a/packages/supabase_flutter/pubspec.yaml b/packages/supabase_flutter/pubspec.yaml index 8de1930d4..d811c41d1 100644 --- a/packages/supabase_flutter/pubspec.yaml +++ b/packages/supabase_flutter/pubspec.yaml @@ -37,6 +37,7 @@ dev_dependencies: dart_jsonwebtoken: ">=3.0.0 <4.0.0" flutter_test: sdk: flutter + shared_preferences_platform_interface: ^2.4.0 supabase_lints: ^0.1.1 web_socket_channel: '>=3.0.0 <4.0.0' diff --git a/packages/supabase_flutter/test/deep_link_test.dart b/packages/supabase_flutter/test/deep_link_test.dart index 7e6c1ec05..ddf66221a 100644 --- a/packages/supabase_flutter/test/deep_link_test.dart +++ b/packages/supabase_flutter/test/deep_link_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'utils.dart'; import 'widget_test_stubs.dart'; void main() { @@ -185,7 +186,7 @@ void main() { test( 'persists the session to the default storage when persistSession is true', () async { - SharedPreferences.setMockInitialValues({}); + mockSharedPreferences(); final pkceHttpClient = PkceHttpClient(); mockAppLink( @@ -212,15 +213,15 @@ void main() { .firstWhere((state) => state.event == AuthChangeEvent.signedIn) .timeout(const Duration(seconds: 5)); - final preferences = await SharedPreferences.getInstance(); - expect(preferences.getString(persistSessionKey), isNotNull); + final preferences = SharedPreferencesAsync(); + expect(await preferences.getString(persistSessionKey), isNotNull); }, ); test( 'does not persist the session when persistSession is false', () async { - SharedPreferences.setMockInitialValues({}); + mockSharedPreferences(); final pkceHttpClient = PkceHttpClient(); mockAppLink( @@ -248,8 +249,8 @@ void main() { .firstWhere((state) => state.event == AuthChangeEvent.signedIn) .timeout(const Duration(seconds: 5)); - final preferences = await SharedPreferences.getInstance(); - expect(preferences.getString(persistSessionKey), isNull); + final preferences = SharedPreferencesAsync(); + expect(await preferences.getString(persistSessionKey), isNull); }, ); }); diff --git a/packages/supabase_flutter/test/dispose_test.dart b/packages/supabase_flutter/test/dispose_test.dart index c18bc3c03..912753b98 100644 --- a/packages/supabase_flutter/test/dispose_test.dart +++ b/packages/supabase_flutter/test/dispose_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'utils.dart'; import 'widget_test_stubs.dart'; void main() { @@ -14,7 +14,7 @@ void main() { test( 'dispose() does not throw when initialized with a custom access token', () async { - SharedPreferences.setMockInitialValues({}); + mockSharedPreferences(); mockAppLink(); await Supabase.initialize( diff --git a/packages/supabase_flutter/test/initialization_test.dart b/packages/supabase_flutter/test/initialization_test.dart index a2608feaa..a1b461bb0 100644 --- a/packages/supabase_flutter/test/initialization_test.dart +++ b/packages/supabase_flutter/test/initialization_test.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'utils.dart'; import 'widget_test_stubs.dart'; void main() { @@ -17,7 +17,7 @@ void main() { group('Supabase initialization', () { setUp(() { - SharedPreferences.setMockInitialValues({}); + mockSharedPreferences(); mockAppLink(); }); diff --git a/packages/supabase_flutter/test/local_storage_migration_test.dart b/packages/supabase_flutter/test/local_storage_migration_test.dart new file mode 100644 index 000000000..bcb64ab56 --- /dev/null +++ b/packages/supabase_flutter/test/local_storage_migration_test.dart @@ -0,0 +1,239 @@ +@TestOn('!browser') +/// Tests for the migration of a v2 session over to [SharedPreferencesAsync]. +/// +/// On web the session is stored in `window.localStorage` under the same key as +/// it was in v2, so there is nothing to migrate there. +library; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SharedPreferencesLocalStorage migration from v2', () { + const persistSessionKey = 'sb-test-auth-token'; + const testSessionValue = '{"key": "value"}'; + + test('moves a session written by the legacy API over', () async { + mockSharedPreferences( + legacyValues: {persistSessionKey: testSessionValue}, + ); + final localStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + await localStorage.initialize(); + + expect(await localStorage.accessToken(), testSessionValue); + expect( + await SharedPreferencesAsync().getString(persistSessionKey), + testSessionValue, + ); + }); + + test('removes the session from the legacy store', () async { + mockSharedPreferences( + legacyValues: {persistSessionKey: testSessionValue}, + ); + final localStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + await localStorage.initialize(); + + final legacyPreferences = await SharedPreferences.getInstance(); + expect(legacyPreferences.getString(persistSessionKey), isNull); + }); + + test('keeps the session of the new store when both have one', () async { + mockSharedPreferences( + legacyValues: {persistSessionKey: '{"key": "legacy"}'}, + ); + await SharedPreferencesAsync().setString( + persistSessionKey, + testSessionValue, + ); + final localStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + await localStorage.initialize(); + + expect(await localStorage.accessToken(), testSessionValue); + }); + + test('does not restore a session that was signed out of', () async { + mockSharedPreferences( + legacyValues: {persistSessionKey: testSessionValue}, + ); + final localStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + await localStorage.initialize(); + await localStorage.removePersistedSession(); + + // A restart of the app, which runs the migration again. + final newLocalStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + await newLocalStorage.initialize(); + + expect(await newLocalStorage.hasAccessToken(), isFalse); + }); + + test( + 'does not restore a signed-out session when both stores had one', + () async { + mockSharedPreferences( + legacyValues: {persistSessionKey: '{"key": "legacy"}'}, + ); + await SharedPreferencesAsync().setString( + persistSessionKey, + testSessionValue, + ); + final localStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + await localStorage.initialize(); + await localStorage.removePersistedSession(); + + // A restart of the app, which runs the migration again. + final newLocalStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + await newLocalStorage.initialize(); + + expect(await newLocalStorage.hasAccessToken(), isFalse); + }, + ); + + test('runs once, so a resurrected legacy entry is ignored', () async { + mockSharedPreferences( + legacyValues: {persistSessionKey: testSessionValue}, + ); + final localStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + await localStorage.initialize(); + await localStorage.removePersistedSession(); + + // Stands in for the platforms where a write through either API can bring + // a deleted entry of the other one back. + final legacyPreferences = await SharedPreferences.getInstance(); + await legacyPreferences.setString(persistSessionKey, testSessionValue); + + final newLocalStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + await newLocalStorage.initialize(); + + expect(await newLocalStorage.hasAccessToken(), isFalse); + }); + + test('keeps the session when the legacy entry cannot be deleted', () async { + mockSharedPreferences(); + SharedPreferencesStorePlatform.instance = _ReadOnlyLegacyStore({ + 'flutter.$persistSessionKey': testSessionValue, + }); + final localStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + + // The new store is written before the legacy entry is deleted, so a + // failure to delete costs a leftover entry rather than the session. + await localStorage.initialize(); + + expect(await localStorage.accessToken(), testSessionValue); + }); + + test('initializes even when the legacy store cannot be read', () async { + mockSharedPreferences(); + SharedPreferencesStorePlatform.instance = _ThrowingLegacyStore(); + final localStorage = SharedPreferencesLocalStorage( + persistSessionKey: persistSessionKey, + ); + + await expectLater(localStorage.initialize(), completes); + await localStorage.persistSession(testSessionValue); + expect(await localStorage.accessToken(), testSessionValue); + }); + }); + + group('SharedPreferencesGotrueAsyncStorage migration from v2', () { + const codeVerifierKey = 'supabase.auth.token-code-verifier'; + const codeVerifier = 'raw-code-verifier'; + + test('moves a code verifier written by the legacy API over', () async { + mockSharedPreferences(legacyValues: {codeVerifierKey: codeVerifier}); + final storage = SharedPreferencesGotrueAsyncStorage(); + + expect(await storage.getItem(key: codeVerifierKey), codeVerifier); + expect( + await SharedPreferencesAsync().getString(codeVerifierKey), + codeVerifier, + ); + final legacyPreferences = await SharedPreferences.getInstance(); + expect(legacyPreferences.getString(codeVerifierKey), isNull); + }); + + test('does not resurrect a verifier that was used up', () async { + mockSharedPreferences(legacyValues: {codeVerifierKey: codeVerifier}); + final storage = SharedPreferencesGotrueAsyncStorage(); + expect(await storage.getItem(key: codeVerifierKey), codeVerifier); + + await storage.removeItem(key: codeVerifierKey); + + expect(await storage.getItem(key: codeVerifierKey), isNull); + }); + + test('returns null when the legacy store cannot be read', () async { + mockSharedPreferences(); + SharedPreferencesStorePlatform.instance = _ThrowingLegacyStore(); + final storage = SharedPreferencesGotrueAsyncStorage(); + + expect(await storage.getItem(key: codeVerifierKey), isNull); + }); + }); +} + +/// Stands in for a legacy store that can be read but not written. +class _ReadOnlyLegacyStore extends SharedPreferencesStorePlatform { + _ReadOnlyLegacyStore(this._data); + + final Map _data; + + @override + Future clear() => throw UnimplementedError(); + + @override + Future> getAll() async => _data; + + @override + Future remove(String key) => + throw MissingPluginException('Store is read only'); + + @override + Future setValue(String valueType, String key, Object value) => + throw MissingPluginException('Store is read only'); +} + +/// Stands in for a platform where the legacy API is unavailable or its store +/// cannot be read. +class _ThrowingLegacyStore extends SharedPreferencesStorePlatform { + @override + Future clear() => throw UnimplementedError(); + + @override + Future> getAll() => + throw MissingPluginException('No implementation found'); + + @override + Future remove(String key) => throw UnimplementedError(); + + @override + Future setValue(String valueType, String key, Object value) => + throw UnimplementedError(); +} diff --git a/packages/supabase_flutter/test/storage_test.dart b/packages/supabase_flutter/test/storage_test.dart index 58fe873f7..4f0e759a3 100644 --- a/packages/supabase_flutter/test/storage_test.dart +++ b/packages/supabase_flutter/test/storage_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'utils.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -9,19 +11,24 @@ void main() { // SharedPreferencesLocalStorage Tests group('SharedPreferencesLocalStorage', () { const testSessionValue = '{"key": "value"}'; + var testCount = 0; Future createFreshLocalStorage() async { - // Use a unique key for each test to ensure complete isolation - final uniqueKey = - 'test_persist_key_${DateTime.now().microsecondsSinceEpoch}'; + // A key per test, counted rather than timestamped: on web the storage + // goes to the window's own localStorage, which outlives the test, and + // `microsecondsSinceEpoch` is only millisecond-resolution there, so two + // tests in the same millisecond used to share a key. + final uniqueKey = 'test_persist_key_${testCount++}'; // Set up fresh shared preferences for each test - SharedPreferences.setMockInitialValues({}); + mockSharedPreferences(); final localStorage = SharedPreferencesLocalStorage( persistSessionKey: uniqueKey, ); await localStorage.initialize(); + // The web store can hold a value from an earlier run under this key. + await localStorage.removePersistedSession(); return localStorage; } @@ -83,18 +90,15 @@ void main() { const testKey = 'test_key'; const testValue = 'test_value'; - setUp(() async { + setUp(() { // Set up fake shared preferences - SharedPreferences.setMockInitialValues({}); + mockSharedPreferences(); asyncStorage = SharedPreferencesGotrueAsyncStorage(); - // Allow for initialization to complete - await Future.delayed(const Duration(milliseconds: 100)); }); test('setItem stores value for key', () async { await asyncStorage.setItem(key: testKey, value: testValue); - final preferences = await SharedPreferences.getInstance(); - final storedValue = preferences.getString(testKey); + final storedValue = await SharedPreferencesAsync().getString(testKey); expect(storedValue, testValue); }); diff --git a/packages/supabase_flutter/test/utils.dart b/packages/supabase_flutter/test/utils.dart index 6e3e822bc..752761cc5 100644 --- a/packages/supabase_flutter/test/utils.dart +++ b/packages/supabase_flutter/test/utils.dart @@ -1,5 +1,19 @@ import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +/// Replaces both shared_preferences APIs with empty in-memory stores. +/// +/// [legacyValues] seeds the store of the legacy [SharedPreferences] API, which +/// `SharedPreferencesLocalStorage` migrates a v2 session from. +void mockSharedPreferences({Map legacyValues = const {}}) { + SharedPreferences.setMockInitialValues(legacyValues); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); +} + /// Construct session data for a given expiration date ({String accessToken, String sessionString}) getSessionData( DateTime accessTokenExpireDateTime, diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 021e9d177..6b5c2dd66 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -2027,7 +2027,7 @@ features: - GoTrueClient.setInitialSession supporting_symbols: - SharedPreferencesLocalStorage.persistSessionKey - - supabasePersistSessionKey + - defaultPersistSessionKey client.request_configuration.custom_http_client: status: implemented symbols: