diff --git a/MIGRATION.md b/MIGRATION.md index 83e430aef..5f1ddf316 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1005,7 +1005,7 @@ final client = SupabaseClient( url, publishableKey, authOptions: AuthClientOptions( - pkceAsyncStorage: MemoryAuthAsyncStorage(), + asyncStorage: MemoryAuthAsyncStorage(), persistSession: true, ), ); @@ -1015,24 +1015,150 @@ A client configured with a third-party `accessToken` has no session of its own a channel. `FlutterAuthClientOptions.persistSession` moved up to `AuthClientOptions` and still defaults to -`true`. In `supabase_flutter` the channel follows the storage that is actually in use: an app that -passes `persistSession: false`, or `localStorage: const EmptyLocalStorage()`, keeps its session in -memory and no longer syncs it across tabs, while a custom `localStorage` counts as persisting and -keeps syncing. +`true`. An app that passes `persistSession: false` keeps its session in memory and no longer syncs +it across tabs. + +### The auth client persists the session itself + +Session persistence used to live in `supabase_flutter`: it listened to `onAuthStateChange` and +wrote the session to a `LocalStorage`, while the pkce code verifiers went to a separate +`AuthAsyncStorage` passed as `pkceAsyncStorage`. Customizing where the session lives meant +implementing two interfaces, and the plain `supabase` package had no session persistence at all. + +`AuthClient` now owns both, the way auth-js does. It takes one `AuthAsyncStorage` for the session +and the code verifiers, writes the session whenever it changes and restores it when the client is +created. Dart programs that do not use Flutter get session persistence out of it too. + +What changed: + +- `LocalStorage`, `EmptyLocalStorage`, `SharedPreferencesLocalStorage` and + `FlutterAuthClientOptions.localStorage` are gone. +- `AuthClientOptions.pkceAsyncStorage` is renamed to `asyncStorage` and holds the session as well. + `SharedPreferencesAuthAsyncStorage` remains the default of `Supabase.initialize`. On web it writes + to `window.localStorage`, so the session is shared with supabase-js under the same key. +- The methods of `AuthAsyncStorage` take positional parameters: `getItem(key)`, + `setItem(key, value)` and `removeItem(key)`. +- `AuthClientOptions.persistSession` decides whether the session is written and restored. It + defaults to `false`, and `FlutterAuthClientOptions` keeps defaulting it to `true`. +- `AuthClientOptions.storageKey` names the key the session is stored under. It defaults to + `defaultPersistSessionKey(url)`, which is `sb--auth-token`, the same key the other + Supabase client libraries use. The key also prefixes the code verifier keys and names the channel + that keeps the tabs of a web app in sync. +- `AuthClient.initialized` completes once the persisted session has been restored. + `Supabase.initialize` awaits it, so `currentSession` is set when it returns, as before. When you + construct a client yourself, await it before reading the session. +- `AuthChangeEvent.initialSession` is emitted to every new subscriber of `onAuthStateChange` as + its first event, carrying the session at that moment or `null`, the way auth-js and + supabase-swift do. Before, it was emitted once at startup by `Supabase.initialize` and the stream + replayed its latest event to late subscribers. A listener attached after a sign-in now receives + `initialSession` with the signed-in session instead of a replayed `signedIn`, and a client you + construct yourself gets the event too. Earlier events and errors are no longer replayed. + +A custom storage implements the one interface and no longer needs to know the key: + +```dart +// Before +class MySecureStorage extends LocalStorage { + MySecureStorage({required this.persistSessionKey}); + + final String persistSessionKey; + + final storage = FlutterSecureStorage(); + + @override + Future initialize() async {} + + @override + Future accessToken() => storage.read(key: persistSessionKey); + + @override + Future hasAccessToken() => storage.containsKey(key: persistSessionKey); + + @override + Future persistSession(String persistSessionString) => + storage.write(key: persistSessionKey, value: persistSessionString); + + @override + Future removePersistedSession() => storage.delete(key: persistSessionKey); +} + +await Supabase.initialize( + url: url, + publishableKey: publishableKey, + authOptions: FlutterAuthClientOptions( + localStorage: MySecureStorage( + persistSessionKey: defaultPersistSessionKey(url), + ), + ), +); + +// After +class MySecureStorage extends AuthAsyncStorage { + final storage = FlutterSecureStorage(); + + @override + Future getItem(String key) => storage.read(key: key); + + @override + Future setItem(String key, String value) => + storage.write(key: key, value: value); + + @override + Future removeItem(String key) => storage.delete(key: key); +} + +await Supabase.initialize( + url: url, + publishableKey: publishableKey, + authOptions: FlutterAuthClientOptions(asyncStorage: MySecureStorage()), +); +``` + +Keeping the session in memory only is a flag rather than a storage: + +```dart +// Before +authOptions: FlutterAuthClientOptions(localStorage: const EmptyLocalStorage()), + +// After +authOptions: FlutterAuthClientOptions(persistSession: false), +``` + +The code verifiers are still stored in that case, so a sign-in through an email link or an OAuth +redirect completes even when the app was closed in between. + +A Dart program persists the session by passing a storage and opting in: + +```dart +final client = SupabaseClient( + url, + publishableKey, + authOptions: AuthClientOptions( + asyncStorage: FileStorage(), // your AuthAsyncStorage + persistSession: true, + ), +); +await client.auth.initialized; +print(client.auth.currentSession?.user.email); +``` + +The code verifiers are stored under `-…` rather than `supabase.auth.token-…`. Verifiers +under the old prefix are still read and cleaned up, so a sign-in link requested before the update +still completes after it. ### The session is persisted with `SharedPreferencesAsync` -`SharedPreferencesLocalStorage` and `SharedPreferencesAuthAsyncStorage`, the storage -implementations `Supabase.initialize` uses by default, wrote through the legacy +`SharedPreferencesAuthAsyncStorage`, the storage `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 +API. It now uses `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`. +prefixes its keys, so a session written by v2 is invisible to the new one. The storage therefore +moves a value over to `SharedPreferencesAsync` the first time it is read 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 pass your own `AuthAsyncStorage`. 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: @@ -1050,48 +1176,31 @@ drop preferences your own code wrote through the legacy API. So if your code sti [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. +If you would rather keep the session in the legacy store for now, pass an `AuthAsyncStorage` that +reads and writes it: ```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); +class LegacySharedPreferencesStorage extends AuthAsyncStorage { + Future get _preferences => SharedPreferences.getInstance(); @override - Future accessToken() async => - _preferences.getString(persistSessionKey); + Future getItem(String key) async => + (await _preferences).getString(key); @override - Future removePersistedSession() => - _preferences.remove(persistSessionKey); + Future setItem(String key, String value) async => + (await _preferences).setString(key, value); @override - Future persistSession(String persistSessionString) => - _preferences.setString(persistSessionKey, persistSessionString); + Future removeItem(String key) async => + (await _preferences).remove(key); } await Supabase.initialize( url: url, publishableKey: publishableKey, authOptions: FlutterAuthClientOptions( - localStorage: LegacySharedPreferencesLocalStorage( - persistSessionKey: defaultPersistSessionKey(url), - ), + asyncStorage: LegacySharedPreferencesStorage(), ), ); ``` @@ -1113,51 +1222,32 @@ setUp(() { ``` `shared_preferences_platform_interface` needs to be a `dev_dependency` for that import. Passing -`FlutterAuthClientOptions(localStorage: const EmptyLocalStorage())` instead skips storage in tests -altogether. +`FlutterAuthClientOptions(asyncStorage: MemoryAuthAsyncStorage())` instead keeps the tests away +from shared preferences 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`. +itself never read it. The session is stored under `AuthClientOptions.storageKey`, which defaults to +`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: +The `LocalStorage` examples in the README used the constant as their storage key. A custom +`AuthAsyncStorage` receives the key with every call, so there is nothing to replace it with in the +storage itself. If you want to keep reading the sessions stored under the constant, pass it as the +key 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), - ), + asyncStorage: MySecureStorage(), + storageKey: 'SUPABASE_PERSIST_SESSION_KEY', ), ); ``` -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. +Leaving the key at its default signs your users out once instead. 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 diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 9fe103278..53e3c8fdc 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -48,7 +48,7 @@ import 'trace_http_client.dart'; /// alone, and it can be shared with other clients. /// /// The pkce flow is used by default and keeps its code verifiers in the -/// `AuthAsyncStorage` passed to the `pkceAsyncStorage` field of [authOptions]. +/// `AuthAsyncStorage` passed to the `asyncStorage` field of [authOptions]. /// Pass a persistent implementation whenever the flow can leave the process /// before the code comes back, which covers every email link and every /// redirect to an OAuth provider. `MemoryAuthAsyncStorage` only suits flows @@ -345,8 +345,9 @@ class SupabaseClient { headers: authHeaders, autoRefreshToken: authOptions.autoRefreshToken, httpClient: _authApiHttpClient, - asyncStorage: authOptions.pkceAsyncStorage, + asyncStorage: authOptions.asyncStorage, persistSession: accessToken == null && authOptions.persistSession, + storageKey: authOptions.storageKey, flowType: authOptions.authFlowType, appendPkceFlowIdToRedirects: authOptions.appendPkceFlowIdToRedirects, retryOptions: authOptions.retryOptions, diff --git a/packages/supabase/lib/src/supabase_client_options.dart b/packages/supabase/lib/src/supabase_client_options.dart index 445411d55..bb309816c 100644 --- a/packages/supabase/lib/src/supabase_client_options.dart +++ b/packages/supabase/lib/src/supabase_client_options.dart @@ -32,8 +32,9 @@ class PostgrestClientOptions { class AuthClientOptions { const AuthClientOptions({ this.autoRefreshToken = true, - this.pkceAsyncStorage, + this.asyncStorage, this.persistSession = false, + this.storageKey, this.authFlowType = AuthFlowType.pkce, this.appendPkceFlowIdToRedirects = false, this.retryOptions = const SupabaseRetryOptions(count: 8), @@ -50,10 +51,12 @@ class AuthClientOptions { /// backoff can squeeze into that window. final SupabaseRetryOptions retryOptions; - /// Storage for the code verifiers of the pkce flow, required when - /// [authFlowType] is [AuthFlowType.pkce]. + /// Storage for the session and the code verifiers of the pkce flow. /// - /// A persistent implementation is needed whenever the flow can leave the + /// Required when [authFlowType] is [AuthFlowType.pkce] or [persistSession] + /// is true. + /// + /// A persistent implementation is needed whenever a pkce flow can leave the /// process before the code comes back. Email links do so by definition, and /// so does a redirect to an OAuth provider, since the app may be reaped /// while it waits and the page context is gone after a web redirect. @@ -64,18 +67,28 @@ class AuthClientOptions { /// listener open. It is also unfit for a server handling more than one user /// at a time, because the verifier is held under a single key that /// concurrent sign-ins overwrite. - final AuthAsyncStorage? pkceAsyncStorage; + final AuthAsyncStorage? asyncStorage; - /// Whether the session is meant to outlive this client. + /// Whether the session is written to [asyncStorage] whenever it changes and + /// restored from there when the client is created. /// - /// The client stores nothing itself. On web a persisted session is kept in - /// sync across the tabs of the same project, so a sign-in or sign-out in one - /// tab reaches the others. Leave it false for a client that must keep its - /// own session, such as one created with the service role key next to the - /// user's client. `supabase_flutter` persists the session and defaults it to - /// true. + /// Await `AuthClient.initialized` to know when the restore is done. On web a + /// persisted session is also kept in sync across the tabs of the same + /// project, so a sign-in or sign-out in one tab reaches the others. Leave it + /// false for a client that must keep its own session, such as one created + /// with the service role key next to the user's client. `supabase_flutter` + /// defaults it to true. final bool persistSession; + /// The key the session is stored under in [asyncStorage]. + /// + /// It also prefixes the keys of the pkce code verifiers and names the + /// channel that keeps the tabs of a web app in sync, so clients for + /// different projects can share one storage. Defaults to the key the other + /// Supabase client libraries derive from the project URL, so a session + /// written by one of them is found by the others. + final String? storageKey; + /// The auth flow used for sign-in, sign-up, and password recovery. final AuthFlowType authFlowType; diff --git a/packages/supabase/test/client_test.dart b/packages/supabase/test/client_test.dart index ce571e23e..12961ea5e 100644 --- a/packages/supabase/test/client_test.dart +++ b/packages/supabase/test/client_test.dart @@ -207,7 +207,7 @@ void main() { }); group('auth', () { - test('the pkce flow asserts when no pkceAsyncStorage is given', () { + test('the pkce flow asserts when no asyncStorage is given', () { expect( () => real.SupabaseClient('http://localhost:1', 'supabaseKey'), throwsA( @@ -225,7 +225,7 @@ void main() { 'http://localhost:1', 'supabaseKey', authOptions: AuthClientOptions( - pkceAsyncStorage: MemoryAuthAsyncStorage(), + asyncStorage: MemoryAuthAsyncStorage(), ), ); addTearDown(supabase.dispose); @@ -627,8 +627,7 @@ class SupabaseClient extends real.SupabaseClient { }) : super( authOptions: AuthClientOptions( autoRefreshToken: authOptions.autoRefreshToken, - pkceAsyncStorage: - authOptions.pkceAsyncStorage ?? MemoryAuthAsyncStorage(), + asyncStorage: authOptions.asyncStorage ?? MemoryAuthAsyncStorage(), authFlowType: authOptions.authFlowType, ), ); diff --git a/packages/supabase/test/postgrest_options_test.dart b/packages/supabase/test/postgrest_options_test.dart index 449a253e9..721cfb8ba 100644 --- a/packages/supabase/test/postgrest_options_test.dart +++ b/packages/supabase/test/postgrest_options_test.dart @@ -18,7 +18,7 @@ void main() { supabaseKey, httpClient: httpClient, authOptions: AuthClientOptions( - pkceAsyncStorage: MemoryAuthAsyncStorage(), + asyncStorage: MemoryAuthAsyncStorage(), ), postgrestOptions: PostgrestClientOptions( retryOptions: retryOptions, diff --git a/packages/supabase/test/stream_filter_test.dart b/packages/supabase/test/stream_filter_test.dart index a7be68105..df3641c92 100644 --- a/packages/supabase/test/stream_filter_test.dart +++ b/packages/supabase/test/stream_filter_test.dart @@ -39,7 +39,7 @@ void main() { supabase = SupabaseClient( 'http://${InternetAddress.loopbackIPv4.address}:${mockServer.port}', localStackServiceRoleKey, - authOptions: AuthClientOptions(pkceAsyncStorage: TestAsyncStorage()), + authOptions: AuthClientOptions(asyncStorage: TestAsyncStorage()), ); }); diff --git a/packages/supabase/test/stream_integration_test.dart b/packages/supabase/test/stream_integration_test.dart index c6dde8225..23bf92d25 100644 --- a/packages/supabase/test/stream_integration_test.dart +++ b/packages/supabase/test/stream_integration_test.dart @@ -544,7 +544,7 @@ const _warmUpPrefix = 'warm_up_'; SupabaseClient _createClient() => SupabaseClient( localStackUrl, localStackServiceRoleKey, - authOptions: AuthClientOptions(pkceAsyncStorage: TestAsyncStorage()), + authOptions: AuthClientOptions(asyncStorage: TestAsyncStorage()), ); /// Listens to [stream] and asserts that it emits [expectedSnapshots] in order, diff --git a/packages/supabase/test/trace_propagation_test.dart b/packages/supabase/test/trace_propagation_test.dart index 757b84da5..f5c19cd93 100644 --- a/packages/supabase/test/trace_propagation_test.dart +++ b/packages/supabase/test/trace_propagation_test.dart @@ -132,7 +132,7 @@ void main() { _supabaseUrl, 'anon-key', tracePropagationOptions: optionsWith(() => context), - authOptions: AuthClientOptions(pkceAsyncStorage: TestAsyncStorage()), + authOptions: AuthClientOptions(asyncStorage: TestAsyncStorage()), httpClient: httpClient..stubTable('table', rows: []), ); addTearDown(supabase.dispose); @@ -146,7 +146,7 @@ void main() { final supabase = SupabaseClient( _supabaseUrl, 'anon-key', - authOptions: AuthClientOptions(pkceAsyncStorage: TestAsyncStorage()), + authOptions: AuthClientOptions(asyncStorage: TestAsyncStorage()), httpClient: httpClient..stubTable('table', rows: []), ); addTearDown(supabase.dispose); diff --git a/packages/supabase_auth/lib/src/auth_client.dart b/packages/supabase_auth/lib/src/auth_client.dart index 1d2a1e1ff..658c316f0 100644 --- a/packages/supabase_auth/lib/src/auth_client.dart +++ b/packages/supabase_auth/lib/src/auth_client.dart @@ -45,17 +45,26 @@ class _SessionState { /// /// [httpClient] custom http client. /// -/// [asyncStorage] local storage to store pkce code verifiers. Required when -/// using the pkce flow. Pass a [MemoryAuthAsyncStorage] when the verifiers -/// do not need to outlive the process. +/// [asyncStorage] storage for the session and the pkce code verifiers. +/// Required when using the pkce flow or persisting the session. Pass a +/// [MemoryAuthAsyncStorage] when neither needs to outlive the process. /// -/// [persistSession] whether the session is meant to outlive this client. On -/// web such a session is kept in sync across the tabs of the same project -/// through a `BroadcastChannel`, so a sign-in or sign-out in one tab reaches -/// the others. Defaults to false: a client that keeps its own session, such as -/// one created with the service role key next to the user's client, must not -/// be signed in by another tab. `supabase_flutter` persists the session and -/// defaults this to true. +/// [persistSession] whether the session is written to [asyncStorage] whenever +/// it changes and restored from there when the client is created, so that it +/// outlives the process. Await [initialized] to know when the restore is done. +/// On web a persisted session is also kept in sync across the tabs of the same +/// project through a `BroadcastChannel`, so a sign-in or sign-out in one tab +/// reaches the others. Defaults to false: a client that keeps its own session, +/// such as one created with the service role key next to the user's client, +/// must not be signed in by another tab. `supabase_flutter` defaults it to +/// true. +/// +/// [storageKey] the key the session is stored under in [asyncStorage]. It also +/// prefixes the keys of the pkce code verifiers and names the channel that +/// keeps the tabs in sync, so clients for different projects can share one +/// storage. Defaults to the key the other Supabase client libraries derive +/// from the project URL, so a session written by one of them is found by the +/// others. /// /// Set [flowType] to [AuthFlowType.implicit] to perform old implicit auth flow. /// @@ -76,6 +85,7 @@ class AuthClient { Client? httpClient, AuthAsyncStorage? asyncStorage, bool persistSession = false, + String? storageKey, AuthFlowType flowType = AuthFlowType.pkce, this.appendPkceFlowIdToRedirects = false, this.retryOptions = const SupabaseRetryOptions(count: 8), @@ -85,14 +95,22 @@ class AuthClient { 'MemoryAuthAsyncStorage when the code verifiers do not need to ' 'outlive the process.', ), + assert( + !persistSession || asyncStorage != null, + 'You need to provide asyncStorage to persist the session.', + ), _url = url ?? AuthConstants.defaultAuthUrl, _headers = {...AuthConstants.defaultHeaders, ...?headers}, _httpClient = httpClient, - _pkceVerifierStore = asyncStorage == null - ? null - : PKCEVerifierStore(asyncStorage), + _asyncStorage = asyncStorage, _persistSession = persistSession, + _storageKey = + storageKey ?? + defaultPersistSessionKey(url ?? AuthConstants.defaultAuthUrl), _flowType = flowType { + _pkceVerifierStore = asyncStorage == null + ? null + : PKCEVerifierStore(asyncStorage, storageKey: _storageKey); _autoRefreshToken = autoRefreshToken ?? true; final authUrl = url ?? AuthConstants.defaultAuthUrl; @@ -118,6 +136,7 @@ class AuthClient { } _mayStartBroadcastChannel(); + unawaited(_restoreSession()); } /// Namespace for the Supabase Auth admin API methods. These can be used for @@ -165,14 +184,42 @@ class AuthClient { JWKSet? _jwks; DateTime? _jwksCachedAt; - final _onAuthStateChangeController = ReplaySubject(); - final _onAuthStateChangeControllerSync = ReplaySubject( - sync: true, - ); + final _onAuthStateChangeController = StreamController.broadcast(); + final _onAuthStateChangeControllerSync = + StreamController.broadcast( + sync: true, + ); /// Keeps one code verifier per pending pkce flow. Null when no /// [AuthAsyncStorage] was provided, in which case the pkce flow cannot run. - final PKCEVerifierStore? _pkceVerifierStore; + late final PKCEVerifierStore? _pkceVerifierStore; + + /// Holds the session while it is persisted and the pkce code verifiers. + final AuthAsyncStorage? _asyncStorage; + + /// The key the session is stored under, see [storageKey]. + final String _storageKey; + + /// The key the session is stored under in the storage. + /// + /// It also prefixes the keys of the pkce code verifiers and names the + /// channel that keeps the tabs of a web app in sync. + String get storageKey => _storageKey; + + final _initialized = Completer(); + + /// Completes once the session persisted by an earlier run has been restored. + /// + /// From then on [currentSession] holds the restored session, and + /// subscribers of [onAuthStateChange] receive their initial event. A + /// restored session that has expired is refreshed in the background, which + /// [onAuthStateChange] reports like any other refresh. Completes right away + /// when the session is not persisted. + Future get initialized => _initialized.future; + + /// The storage writes that have not completed yet, run one after the other + /// so a later state cannot be overtaken by an earlier write. + Future _storageWrites = Future.value(); /// Whether the reserved `sb_flow_id` query parameter is appended to the /// redirect URL of pkce flows, so a callback can be matched to the flow that @@ -191,6 +238,11 @@ class AuthClient { /// Receive a notification every time an auth event happens. /// + /// Every subscriber first receives an [AuthChangeEvent.initialSession] with + /// the session at that moment, or `null` when there is none, once the + /// session persisted by an earlier run has been restored. Earlier events are + /// not replayed. + /// /// Network errors (e.g. when the device is offline) are emitted as stream /// errors. You **must** supply an `onError` handler when calling `.listen()`, /// otherwise Dart will rethrow the error as an unhandled zone exception and @@ -219,12 +271,70 @@ class AuthClient { /// ); /// ``` Stream get onAuthStateChange => - _onAuthStateChangeController.stream; + _withInitialSession(_onAuthStateChangeController.stream, sync: false); /// Don't use this, it's for internal use only. @internal Stream get onAuthStateChangeSync => - _onAuthStateChangeControllerSync.stream; + _withInitialSession(_onAuthStateChangeControllerSync.stream, sync: true); + + /// Wraps [events] so that every subscriber first receives an + /// [AuthChangeEvent.initialSession] with the session at that moment. + /// + /// The initial event waits for [initialized], so a subscriber that arrives + /// while the persisted session is still being read gets the restored session + /// rather than a null that is about to change. Events that fire in the + /// meantime are held back until then, so the initial event stays first. + Stream _withInitialSession( + Stream events, { + required bool sync, + }) { + return Stream.multi((controller) { + var initialSent = false; + final held = []; + + void forward(void Function() deliver) { + if (initialSent) { + deliver(); + } else { + held.add(deliver); + } + } + + final subscription = events.listen( + (state) => forward(() => controller.addSync(state)), + onError: (Object error, StackTrace stackTrace) => + forward(() => controller.addErrorSync(error, stackTrace)), + onDone: () => forward(controller.closeSync), + ); + controller.onCancel = subscription.cancel; + + void sendInitial() { + if (controller.isClosed) { + return; + } + initialSent = true; + controller.addSync( + AuthState(AuthChangeEvent.initialSession, currentSession), + ); + for (final deliver in held) { + deliver(); + } + held.clear(); + } + + // A completed future runs its callbacks in the zone it was created in, + // which is not the subscriber's zone under a fake async clock, so the + // initial event is scheduled directly once the restore is done. + if (!_initialized.isCompleted) { + unawaited(_initialized.future.then((_) => sendInitial())); + } else if (sync) { + sendInitial(); + } else { + scheduleMicrotask(sendInitial); + } + }, isBroadcast: true); + } final AuthFlowType _flowType; @@ -1111,7 +1221,10 @@ class AuthClient { ); final userResponse = UserResponse.fromJson(response); - _currentSession = currentSession?.copyWith(user: userResponse.user); + final session = currentSession; + if (session != null) { + _saveSession(session.copyWith(user: userResponse.user)); + } notifyAllSubscribers(AuthChangeEvent.userUpdated); return userResponse; @@ -1699,22 +1812,132 @@ class AuthClient { return url; } - /// set currentSession and currentUser + /// Sets the current session and persists it. void _saveSession(Session session) { authLogger.fine('Saving session'); authLogger.finest('Saving session: $session'); _currentSession = session; + _queueStorageWrite( + (storage) => storage.setItem(_storageKey, jsonEncode(session.toJson())), + ); } + /// Clears the current session and removes the persisted one. void _removeSession() { authLogger.fine('Removing session'); _currentSession = null; + _queueStorageWrite((storage) => storage.removeItem(_storageKey)); + } + + /// Runs [write] after the storage writes queued before it, when the session + /// is persisted. + /// + /// A failed write is logged rather than thrown: the session change it was + /// mirroring has already happened, and the user only loses the session at + /// the next start. + void _queueStorageWrite( + Future Function(AuthAsyncStorage storage) write, + ) { + final storage = _asyncStorage; + if (!_persistSession || storage == null) { + return; + } + _storageWrites = _storageWrites + .then((_) => write(storage)) + .then( + (_) {}, + onError: (Object error, StackTrace stackTrace) { + authLogger.warning( + 'Could not update the persisted session', + error, + stackTrace, + ); + }, + ); + } + + /// Restores the session persisted by an earlier run, when there is one, and + /// completes [initialized]. + /// + /// An expired session is refreshed after [initialized] completes, so that + /// waiting for the restore never waits for the network. + Future _restoreSession() async { + final storage = _asyncStorage; + if (!_persistSession || storage == null) { + _initialized.complete(); + return; + } + String? expired; + try { + expired = await _readPersistedSession(storage); + } finally { + _initialized.complete(); + } + if (expired == null) { + return; + } + unawaited( + recoverSession(expired).then( + (_) {}, + onError: (Object error, StackTrace stackTrace) { + // Already reported on the stream by recoverSession itself. + authLogger.fine('Could not refresh the restored session', error); + }, + ), + ); + } + + /// Makes the session persisted in [storage] the current one. + /// + /// A value that does not hold a session is removed from the storage. A + /// sign-in or sign-out that happened while the storage was being read is + /// newer than what was read, so the current session is kept in that case. + /// + /// Returns the persisted value when the session it holds has expired, so + /// that the caller can refresh it. + Future _readPersistedSession(AuthAsyncStorage storage) async { + final versionBeforeRead = _sessionVersion; + String? persisted; + try { + persisted = await storage.getItem(_storageKey); + } catch (error, stackTrace) { + authLogger.warning( + 'Could not read the persisted session', + error, + stackTrace, + ); + } + if (_isDisposed) { + return null; + } + if (_sessionVersion != versionBeforeRead) { + authLogger.fine('Session changed during restore, keeping it'); + return null; + } + Session? session; + if (persisted != null) { + try { + session = Session.fromJson(json.decode(persisted)); + } catch (error, stackTrace) { + authLogger.warning( + 'Could not restore the persisted session', + error, + stackTrace, + ); + } + if (session == null) { + _removeSession(); + } else { + _currentSession = session; + } + } + return session != null && session.isExpired ? persisted : null; } void _mayStartBroadcastChannel() { if (_persistSession && const bool.fromEnvironment('dart.library.js_interop')) { - final broadcastKey = defaultPersistSessionKey(_url); + final broadcastKey = _storageKey; assert( _broadcastChannel == null, @@ -1737,11 +1960,9 @@ class AuthClient { if (messageEvent['session'] != null) { session = Session.fromJson(messageEvent['session']); } - if (session != null) { - _saveSession(session); - } else { - _removeSession(); - } + // The tab that sent the event has already written the session + // to the storage both tabs share. + _currentSession = session; notifyAllSubscribers(event, session: session, broadcast: false); } }); diff --git a/packages/supabase_auth/lib/src/auth_constants.dart b/packages/supabase_auth/lib/src/auth_constants.dart index 26f22df0a..1b379572b 100644 --- a/packages/supabase_auth/lib/src/auth_constants.dart +++ b/packages/supabase_auth/lib/src/auth_constants.dart @@ -9,8 +9,10 @@ class AuthConstants { 'X-Client-Info': buildClientInfoHeader('gotrue-dart', version), }; - /// storage key prefix to store code verifiers - static const String defaultStorageKey = 'supabase.auth.token'; + /// The prefix code verifiers were stored under before they were keyed by + /// `AuthClient.storageKey`. Still read so a flow started before the change + /// can complete. + static const String legacyStorageKey = 'supabase.auth.token'; /// Maximum number of PKCE code verifiers kept in storage at once. Starting /// another flow beyond this evicts the oldest pending verifier. diff --git a/packages/supabase_auth/lib/src/constants.dart b/packages/supabase_auth/lib/src/constants.dart index 835c62bac..5d50d6ec3 100644 --- a/packages/supabase_auth/lib/src/constants.dart +++ b/packages/supabase_auth/lib/src/constants.dart @@ -3,8 +3,10 @@ import 'package:supabase_common/supabase_common.dart'; /// The kind of change reported on `AuthClient.onAuthStateChange`. enum AuthChangeEvent { - /// Emitted once at startup with the session restored from storage, or - /// `null` if there was none. + /// Emitted to every new subscriber of `AuthClient.onAuthStateChange` as its + /// first event, with the session at that moment or `null` if there is none. + /// A subscriber that arrives while a persisted session is still being + /// restored receives it once the restore is done. initialSession, /// Emitted after the user follows a password recovery link. diff --git a/packages/supabase_auth/lib/src/pkce_verifier_store.dart b/packages/supabase_auth/lib/src/pkce_verifier_store.dart index de6ab91cf..fd2007931 100644 --- a/packages/supabase_auth/lib/src/pkce_verifier_store.dart +++ b/packages/supabase_auth/lib/src/pkce_verifier_store.dart @@ -19,12 +19,20 @@ import 'package:supabase_common/supabase_common.dart'; /// The verifier of the most recently started flow is also written to the key /// that was used before slots existed, so an exchange that cannot identify its /// flow keeps working exactly as it did. +/// +/// Every key is prefixed with [storageKey], so clients for different projects +/// can share one storage. Keys under [AuthConstants.legacyStorageKey], the +/// prefix used before, are still read and cleaned up so a flow that started +/// before the prefix changed can complete. @internal class PKCEVerifierStore { - PKCEVerifierStore(this._storage); + PKCEVerifierStore(this._storage, {required this.storageKey}); final AuthAsyncStorage _storage; + /// The prefix of every key this store writes. + final String storageKey; + /// The mutation the next one has to wait for, null while none is in flight. /// /// [store], [remove] and [removeAll] each read the index and write it back @@ -52,12 +60,26 @@ class PKCEVerifierStore { /// this store generates is rejected before it is used to build a key. static final _flowIdPattern = RegExp(r'^[a-zA-Z0-9_-]{8,64}$'); - static const _legacyKey = '${AuthConstants.defaultStorageKey}-code-verifier'; - static const _indexKey = - '${AuthConstants.defaultStorageKey}-flows-code-verifier'; + /// The prefixes a pending verifier may be stored under: [storageKey], and + /// [AuthConstants.legacyStorageKey] for a flow started before the change. + late final List _prefixes = { + storageKey, + AuthConstants.legacyStorageKey, + }.toList(); + + /// The key the most recently started flow is mirrored under, which is the + /// key that was used before slots existed. + static String _mirrorKey(String prefix) => '$prefix-code-verifier'; + + static String _indexKeyOf(String prefix) => '$prefix-flows-code-verifier'; + + static String _slotKeyOf(String prefix, String flowId) => + '$prefix-flow-$flowId-code-verifier'; + + String get _legacyKey => _mirrorKey(storageKey); + String get _indexKey => _indexKeyOf(storageKey); - static String _slotKey(String flowId) => - '${AuthConstants.defaultStorageKey}-flow-$flowId-code-verifier'; + String _slotKey(String flowId) => _slotKeyOf(storageKey, flowId); /// Returns [flowId] when it has the shape of a flow id, `null` otherwise. static String? validateFlowId(String? flowId) => @@ -92,21 +114,23 @@ class PKCEVerifierStore { required String flowId, required String verifier, }) async { - await _storage.setItem(key: _slotKey(flowId), value: verifier); + await _storage.setItem(_slotKey(flowId), verifier); - final index = (await _readIndex()).where((id) => id != flowId).toList() + final index = await _readIndex(_indexKey); + index + ..remove(flowId) ..add(flowId); final evicted = []; while (index.length > AuthConstants.pkceMaxConcurrentFlows) { final oldest = index.removeAt(0); - await _storage.removeItem(key: _slotKey(oldest)); + await _storage.removeItem(_slotKey(oldest)); evicted.add(oldest); } - await _storage.setItem(key: _indexKey, value: jsonEncode(index)); + await _storage.setItem(_indexKey, jsonEncode(index)); // Mirror the most recently started flow under the key used before slots // existed, so an exchange that carries no flow id behaves as it always has. - await _storage.setItem(key: _legacyKey, value: verifier); + await _storage.setItem(_legacyKey, verifier); return evicted; } @@ -117,8 +141,17 @@ class PKCEVerifierStore { /// A given [flowId] is looked up in its slot only, deliberately without /// falling back to the key used before slots existed: submitting another /// flow's verifier would spend the single-use auth code. - Future retrieve({String? flowId}) => - _storage.getItem(key: flowId == null ? _legacyKey : _slotKey(flowId)); + Future retrieve({String? flowId}) async { + for (final prefix in _prefixes) { + final verifier = await _storage.getItem( + flowId == null ? _mirrorKey(prefix) : _slotKeyOf(prefix, flowId), + ); + if (verifier != null) { + return verifier; + } + } + return null; + } /// Removes the verifier of [flowId], or the one of the most recently started /// flow when [flowId] is `null`. @@ -132,39 +165,49 @@ class PKCEVerifierStore { Future _remove({String? flowId}) async { final verifier = await retrieve(flowId: flowId); - final index = await _readIndex(); + for (final prefix in _prefixes) { + await _removeUnder(prefix, flowId: flowId, verifier: verifier); + } + } - // Without a flow id the verifier came from the legacy key, which mirrors - // whichever flow started last. Its slot is found by value, since the legacy - // key does not record which flow that was. + /// Removes the spent [verifier] from its slot and the mirror key under + /// [prefix], and drops the slot from the index kept there. + /// + /// Without a flow id the verifier came from the mirror key, which reflects + /// whichever flow started last. Its slot is found by value, since the mirror + /// key does not record which flow that was. + Future _removeUnder( + String prefix, { + required String? flowId, + required String? verifier, + }) async { + final indexKey = _indexKeyOf(prefix); + final index = await _readIndex(indexKey); final spentFlowIds = flowId != null ? [flowId] : verifier == null ? const [] : [ for (final id in index) - if (await _storage.getItem(key: _slotKey(id)) == verifier) id, + if (await _storage.getItem(_slotKeyOf(prefix, id)) == verifier) + id, ]; for (final spentFlowId in spentFlowIds) { - await _storage.removeItem(key: _slotKey(spentFlowId)); - } - - final remaining = index.where((id) => !spentFlowIds.contains(id)).toList(); - if (remaining.length != index.length) { - if (remaining.isEmpty) { - await _storage.removeItem(key: _indexKey); - } else { - await _storage.setItem(key: _indexKey, value: jsonEncode(remaining)); - } + await _storage.removeItem(_slotKeyOf(prefix, spentFlowId)); } + await _writeIndex( + indexKey, + index, + index.where((id) => !spentFlowIds.contains(id)).toList(), + ); - // The legacy key mirrors the most recently started flow, which may be this + // The mirror key holds the most recently started flow, which may be this // one. Leaving a spent verifier there would let a later exchange without a // flow id reuse it. - final legacyVerifier = await _storage.getItem(key: _legacyKey); - if (verifier != null && verifier == legacyVerifier) { - await _storage.removeItem(key: _legacyKey); + final mirrorKey = _mirrorKey(prefix); + if (verifier != null && verifier == await _storage.getItem(mirrorKey)) { + await _storage.removeItem(mirrorKey); } } @@ -172,17 +215,37 @@ class PKCEVerifierStore { Future removeAll() => _serialize(_removeAll); Future _removeAll() async { - for (final flowId in await _readIndex()) { - await _storage.removeItem(key: _slotKey(flowId)); + for (final prefix in _prefixes) { + final indexKey = _indexKeyOf(prefix); + for (final flowId in await _readIndex(indexKey)) { + await _storage.removeItem(_slotKeyOf(prefix, flowId)); + } + await _storage.removeItem(indexKey); + await _storage.removeItem(_mirrorKey(prefix)); + } + } + + /// Stores [remaining] under [key] when it differs from [previous], dropping + /// the key when nothing is left. + Future _writeIndex( + String key, + List previous, + List remaining, + ) async { + if (remaining.length == previous.length) { + return; + } + if (remaining.isEmpty) { + await _storage.removeItem(key); + } else { + await _storage.setItem(key, jsonEncode(remaining)); } - await _storage.removeItem(key: _indexKey); - await _storage.removeItem(key: _legacyKey); } /// The index goes through the same validation as a flow id read off a URL: /// with cookie backed storage its contents are no more trustworthy. - Future> _readIndex() async { - final index = await _storage.getItem(key: _indexKey); + Future> _readIndex(String key) async { + final index = await _storage.getItem(key); if (index == null) { return []; } diff --git a/packages/supabase_auth/lib/src/types/auth_async_storage.dart b/packages/supabase_auth/lib/src/types/auth_async_storage.dart index 02c75d56b..4a0a47f59 100644 --- a/packages/supabase_auth/lib/src/types/auth_async_storage.dart +++ b/packages/supabase_auth/lib/src/types/auth_async_storage.dart @@ -1,39 +1,42 @@ -/// Interface to provide async storage to store pkce tokens. +/// Key-value storage the auth client keeps its session and pkce code +/// verifiers in. +/// +/// The session is written under `AuthClient.storageKey` whenever it changes +/// and read back when a client is created, so that it outlives the process. +/// Code verifiers are stored under keys prefixed with the same key while +/// their pkce flow is pending. abstract class AuthAsyncStorage { const AuthAsyncStorage(); - /// Retrieves an item asynchronously from the storage with the key. - Future getItem({required String key}); + /// Returns the value stored under [key], or `null` when there is none. + Future getItem(String key); - /// Stores the value asynchronously to the storage with the key. - Future setItem({ - required String key, - required String value, - }); + /// Stores [value] under [key], replacing any earlier value. + Future setItem(String key, String value); - /// Removes an item asynchronously from the storage for the given key. - Future removeItem({required String key}); + /// Removes the value stored under [key], if any. + Future removeItem(String key); } -/// A [AuthAsyncStorage] that keeps the pkce code verifiers in memory only. +/// An [AuthAsyncStorage] that keeps everything in memory only. /// -/// Everything it holds is lost when the process exits, so a pkce flow started -/// before a restart can no longer be completed. Use a persistent -/// implementation when the code exchange happens after the app was closed, -/// which is what `supabase_flutter` does with shared preferences. +/// Everything it holds is lost when the process exits, so a session is not +/// restored after a restart and a pkce flow started before one can no longer +/// be completed. Use a persistent implementation when either needs to outlive +/// the process, which is what `supabase_flutter` does with shared preferences. class MemoryAuthAsyncStorage extends AuthAsyncStorage { final _items = {}; @override - Future getItem({required String key}) async => _items[key]; + Future getItem(String key) async => _items[key]; @override - Future setItem({required String key, required String value}) async { + Future setItem(String key, String value) async { _items[key] = value; } @override - Future removeItem({required String key}) async { + Future removeItem(String key) async { _items.remove(key); } } diff --git a/packages/supabase_auth/lib/supabase_auth.dart b/packages/supabase_auth/lib/supabase_auth.dart index 5b589c2f6..afd0d9d01 100644 --- a/packages/supabase_auth/lib/supabase_auth.dart +++ b/packages/supabase_auth/lib/supabase_auth.dart @@ -2,7 +2,11 @@ library; export 'package:supabase_common/supabase_common.dart' - show SupabaseApiException, SupabaseException, SupabaseRetryOptions; + show + SupabaseApiException, + SupabaseException, + SupabaseRetryOptions, + defaultPersistSessionKey; export 'src/constants.dart'; export 'src/auth_admin_api.dart'; diff --git a/packages/supabase_auth/test/client_test.dart b/packages/supabase_auth/test/client_test.dart index 52454ea71..f64e9e61a 100644 --- a/packages/supabase_auth/test/client_test.dart +++ b/packages/supabase_auth/test/client_test.dart @@ -171,6 +171,9 @@ void main() { expect( stream, emitsInOrder([ + predicate( + (event) => event.event == AuthChangeEvent.initialSession, + ), predicate( (event) => event.event == AuthChangeEvent.signedIn, ), @@ -334,9 +337,12 @@ void main() { expect( newClient.onAuthStateChange, - emits( + emitsInOrder([ + predicate( + (s) => s.event == AuthChangeEvent.initialSession, + ), predicate((s) => s.event == AuthChangeEvent.signedIn), - ), + ]), ); final response = await newClient.setSession( @@ -628,7 +634,9 @@ void main() { stream, emitsInOrder([ predicate( - (event) => event.event == AuthChangeEvent.signedIn, + (event) => + event.event == AuthChangeEvent.initialSession && + event.session != null, ), predicate( (event) => event.event == AuthChangeEvent.signedOut, @@ -715,6 +723,9 @@ void main() { }); test('Session recovery succeeds after retries', () async { + final event = client.onAuthStateChange.firstWhere( + (state) => state.event != AuthChangeEvent.initialSession, + ); try { await client.recoverSession( '{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OD' @@ -738,9 +749,8 @@ void main() { } on ClientException { // the method should throw } - final event = await client.onAuthStateChange.first; expect(httpClient.retryCount, 4); - expect(event.event, AuthChangeEvent.tokenRefreshed); + expect((await event).event, AuthChangeEvent.tokenRefreshed); }); }); @@ -892,6 +902,8 @@ void main() { await pumpEventQueue(); expect(events, [ + // creating the client + AuthChangeEvent.initialSession, // signInWithPassword AuthChangeEvent.signedIn, // updateUser requested the change diff --git a/packages/supabase_auth/test/memory_async_storage_test.dart b/packages/supabase_auth/test/memory_async_storage_test.dart index 21532093b..30fc90d80 100644 --- a/packages/supabase_auth/test/memory_async_storage_test.dart +++ b/packages/supabase_auth/test/memory_async_storage_test.dart @@ -9,28 +9,28 @@ void main() { }); test('returns null for a key that was never stored', () async { - expect(await storage.getItem(key: 'code-verifier'), isNull); + expect(await storage.getItem('code-verifier'), isNull); }); test('returns the value that was stored last', () async { - await storage.setItem(key: 'code-verifier', value: 'first'); - await storage.setItem(key: 'code-verifier', value: 'second'); + await storage.setItem('code-verifier', 'first'); + await storage.setItem('code-verifier', 'second'); - expect(await storage.getItem(key: 'code-verifier'), 'second'); + expect(await storage.getItem('code-verifier'), 'second'); }); test('forgets a removed key', () async { - await storage.setItem(key: 'code-verifier', value: 'value'); - await storage.removeItem(key: 'code-verifier'); + await storage.setItem('code-verifier', 'value'); + await storage.removeItem('code-verifier'); - expect(await storage.getItem(key: 'code-verifier'), isNull); + expect(await storage.getItem('code-verifier'), isNull); }); test('keeps the entries of two instances apart', () async { - await storage.setItem(key: 'code-verifier', value: 'value'); + await storage.setItem('code-verifier', 'value'); final other = MemoryAuthAsyncStorage(); - expect(await other.getItem(key: 'code-verifier'), isNull); + expect(await other.getItem('code-verifier'), isNull); }); } diff --git a/packages/supabase_auth/test/otp_mock_test.dart b/packages/supabase_auth/test/otp_mock_test.dart index d854fad0d..29247b7a6 100644 --- a/packages/supabase_auth/test/otp_mock_test.dart +++ b/packages/supabase_auth/test/otp_mock_test.dart @@ -1,5 +1,4 @@ import 'package:supabase_auth/supabase_auth.dart'; -import 'package:supabase_auth/src/auth_constants.dart' show AuthConstants; import 'package:test/test.dart'; import 'mocks/otp_mock_client.dart'; @@ -422,7 +421,7 @@ void main() { await client.updateUser(UserAttributes(email: testEmail)); final storedVerifier = await asyncStorage.getItem( - key: '${AuthConstants.defaultStorageKey}-code-verifier', + '${client.storageKey}-code-verifier', ); expect( storedVerifier?.split('/').last, diff --git a/packages/supabase_auth/test/pkce_flow_test.dart b/packages/supabase_auth/test/pkce_flow_test.dart index bd23b7209..bdaf87369 100644 --- a/packages/supabase_auth/test/pkce_flow_test.dart +++ b/packages/supabase_auth/test/pkce_flow_test.dart @@ -74,30 +74,37 @@ class SlowAsyncStorage extends AuthAsyncStorage { Future get _delay => Future.delayed(Duration.zero); @override - Future getItem({required String key}) async { + Future getItem(String key) async { await _delay; return _items[key]; } @override - Future setItem({required String key, required String value}) async { + Future setItem(String key, String value) async { await _delay; _items[key] = value; } @override - Future removeItem({required String key}) async { + Future removeItem(String key) async { await _delay; _items.remove(key); } } void main() { - const legacyKey = '${AuthConstants.defaultStorageKey}-code-verifier'; - const indexKey = '${AuthConstants.defaultStorageKey}-flows-code-verifier'; + const storageKey = 'sb-test-auth-token'; + const legacyKey = '$storageKey-code-verifier'; + const indexKey = '$storageKey-flows-code-verifier'; - String slotKey(String flowId) => - '${AuthConstants.defaultStorageKey}-flow-$flowId-code-verifier'; + String slotKey(String flowId) => '$storageKey-flow-$flowId-code-verifier'; + + const legacyPrefixKey = '${AuthConstants.legacyStorageKey}-code-verifier'; + const legacyPrefixIndexKey = + '${AuthConstants.legacyStorageKey}-flows-code-verifier'; + + String legacyPrefixSlotKey(String flowId) => + '${AuthConstants.legacyStorageKey}-flow-$flowId-code-verifier'; group('PKCEVerifierStore', () { late TestAsyncStorage storage; @@ -105,7 +112,67 @@ void main() { setUp(() { storage = TestAsyncStorage(); - store = PKCEVerifierStore(storage); + store = PKCEVerifierStore(storage, storageKey: storageKey); + }); + + test('reads a verifier stored under the prefix used before', () async { + await storage.setItem(legacyPrefixKey, 'verifier-old'); + await storage.setItem(legacyPrefixSlotKey('flow-old'), 'verifier-old'); + + expect(await store.retrieve(), 'verifier-old'); + expect(await store.retrieve(flowId: 'flow-old'), 'verifier-old'); + }); + + test('prefers the verifier under the current prefix', () async { + await storage.setItem(legacyPrefixKey, 'verifier-old'); + await store.store(flowId: 'flow-one', verifier: 'verifier-one'); + + expect(await store.retrieve(), 'verifier-one'); + }); + + test('removes a spent verifier from the prefix used before', () async { + await storage.setItem(legacyPrefixKey, 'verifier-old'); + await storage.setItem(legacyPrefixSlotKey('flow-old'), 'verifier-old'); + + await store.remove(flowId: 'flow-old'); + + expect(await storage.getItem(legacyPrefixSlotKey('flow-old')), isNull); + expect(await storage.getItem(legacyPrefixKey), isNull); + }); + + test('remove without a flow id clears the matching slot of the prefix ' + 'used before', () async { + await storage.setItem(legacyPrefixKey, 'verifier-old'); + await storage.setItem(legacyPrefixIndexKey, '["flow-old","flow-other"]'); + await storage.setItem(legacyPrefixSlotKey('flow-old'), 'verifier-old'); + await storage.setItem( + legacyPrefixSlotKey('flow-other'), + 'verifier-other', + ); + + await store.remove(); + + expect(await storage.getItem(legacyPrefixKey), isNull); + expect(await storage.getItem(legacyPrefixSlotKey('flow-old')), isNull); + expect( + await storage.getItem(legacyPrefixSlotKey('flow-other')), + 'verifier-other', + ); + expect(jsonDecode(await storage.getItem(legacyPrefixIndexKey) ?? ''), [ + 'flow-other', + ]); + }); + + test('removeAll clears the keys of the prefix used before', () async { + await storage.setItem(legacyPrefixKey, 'verifier-old'); + await storage.setItem(legacyPrefixIndexKey, '["flow-old"]'); + await storage.setItem(legacyPrefixSlotKey('flow-old'), 'verifier-old'); + + await store.removeAll(); + + expect(await storage.getItem(legacyPrefixKey), isNull); + expect(await storage.getItem(legacyPrefixIndexKey), isNull); + expect(await storage.getItem(legacyPrefixSlotKey('flow-old')), isNull); }); test('keeps concurrent flows in slots of their own', () async { @@ -123,7 +190,7 @@ void main() { await store.store(flowId: 'flow-two', verifier: 'verifier-two'); expect(await store.retrieve(), 'verifier-two'); - expect(await storage.getItem(key: legacyKey), 'verifier-two'); + expect(await storage.getItem(legacyKey), 'verifier-two'); }, ); @@ -186,7 +253,7 @@ void main() { await store.remove(flowId: 'flow-one'); - expect(await storage.getItem(key: legacyKey), isNull); + expect(await storage.getItem(legacyKey), isNull); }); test('removing a flow keeps a legacy key of a newer flow', () async { @@ -195,7 +262,7 @@ void main() { await store.remove(flowId: 'flow-one'); - expect(await storage.getItem(key: legacyKey), 'verifier-two'); + expect(await storage.getItem(legacyKey), 'verifier-two'); }); test('removing without a flow id also clears the owning slot', () async { @@ -203,9 +270,9 @@ void main() { await store.remove(); - expect(await storage.getItem(key: legacyKey), isNull); + expect(await storage.getItem(legacyKey), isNull); expect(await store.retrieve(flowId: 'flow-one'), isNull); - expect(await storage.getItem(key: indexKey), isNull); + expect(await storage.getItem(indexKey), isNull); }); test('removing without a flow id keeps the older flows', () async { @@ -216,7 +283,7 @@ void main() { expect(await store.retrieve(flowId: 'flow-two'), isNull); expect(await store.retrieve(flowId: 'flow-one'), 'verifier-one'); - expect(jsonDecode(await storage.getItem(key: indexKey) ?? ''), [ + expect(jsonDecode(await storage.getItem(indexKey) ?? ''), [ 'flow-one', ]); }); @@ -227,31 +294,31 @@ void main() { await store.removeAll(); - expect(await storage.getItem(key: slotKey('flow-one')), isNull); - expect(await storage.getItem(key: slotKey('flow-two')), isNull); - expect(await storage.getItem(key: indexKey), isNull); - expect(await storage.getItem(key: legacyKey), isNull); + expect(await storage.getItem(slotKey('flow-one')), isNull); + expect(await storage.getItem(slotKey('flow-two')), isNull); + expect(await storage.getItem(indexKey), isNull); + expect(await storage.getItem(legacyKey), isNull); }); test('ignores an index that is not a list of flow ids', () async { - await storage.setItem(key: indexKey, value: 'not json at all'); + await storage.setItem(indexKey, 'not json at all'); await store.store(flowId: 'flow-one', verifier: 'verifier-one'); expect(await store.retrieve(flowId: 'flow-one'), 'verifier-one'); - expect(jsonDecode(await storage.getItem(key: indexKey) ?? ''), [ + expect(jsonDecode(await storage.getItem(indexKey) ?? ''), [ 'flow-one', ]); }); test('drops index entries that are not shaped like a flow id', () async { await storage.setItem( - key: indexKey, - value: jsonEncode(['../escape', 7, 'flow-one']), + indexKey, + jsonEncode(['../escape', 7, 'flow-one']), ); await store.removeAll(); - expect(await storage.getItem(key: slotKey('flow-one')), isNull); + expect(await storage.getItem(slotKey('flow-one')), isNull); }); test('rejects a flow id it could never evict again', () async { @@ -260,21 +327,21 @@ void main() { throwsA(isA()), ); - expect(await storage.getItem(key: indexKey), isNull); - expect(await storage.getItem(key: legacyKey), isNull); - expect(await storage.getItem(key: slotKey('../escape')), isNull); + expect(await storage.getItem(indexKey), isNull); + expect(await storage.getItem(legacyKey), isNull); + expect(await storage.getItem(slotKey('../escape')), isNull); }); test('keeps every concurrently started flow in the index', () async { final slowStorage = SlowAsyncStorage(); - final slowStore = PKCEVerifierStore(slowStorage); + final slowStore = PKCEVerifierStore(slowStorage, storageKey: storageKey); await Future.wait([ slowStore.store(flowId: 'flow-one', verifier: 'verifier-one'), slowStore.store(flowId: 'flow-two', verifier: 'verifier-two'), ]); - expect(jsonDecode(await slowStorage.getItem(key: indexKey) ?? ''), [ + expect(jsonDecode(await slowStorage.getItem(indexKey) ?? ''), [ 'flow-one', 'flow-two', ]); @@ -282,8 +349,8 @@ void main() { // An untracked slot is one removeAll cannot reach, so a verifier would // outlive the sign out that was supposed to clear it. await slowStore.removeAll(); - expect(await slowStorage.getItem(key: slotKey('flow-one')), isNull); - expect(await slowStorage.getItem(key: slotKey('flow-two')), isNull); + expect(await slowStorage.getItem(slotKey('flow-one')), isNull); + expect(await slowStorage.getItem(slotKey('flow-two')), isNull); }); group('validateFlowId', () { @@ -450,7 +517,7 @@ void main() { provider: OAuthProvider.github, ); - final store = PKCEVerifierStore(storage); + final store = PKCEVerifierStore(storage, storageKey: client.storageKey); final firstVerifier = await store.retrieve(flowId: first.flowId); final secondVerifier = await store.retrieve(flowId: second.flowId); @@ -467,6 +534,7 @@ void main() { final expectedVerifier = await PKCEVerifierStore( storage, + storageKey: client.storageKey, ).retrieve(flowId: first.flowId); await client.exchangeCodeForSession( @@ -485,13 +553,17 @@ void main() { final expectedVerifier = await PKCEVerifierStore( storage, + storageKey: client.storageKey, ).retrieve(flowId: second.flowId); await client.exchangeCodeForSession('my-auth-code'); expect(mockClient.submittedCodeVerifiers, [expectedVerifier]); expect( - await PKCEVerifierStore(storage).retrieve(flowId: second.flowId), + await PKCEVerifierStore( + storage, + storageKey: client.storageKey, + ).retrieve(flowId: second.flowId), isNull, ); }); @@ -504,7 +576,7 @@ void main() { provider: OAuthProvider.github, ); - final store = PKCEVerifierStore(storage); + final store = PKCEVerifierStore(storage, storageKey: client.storageKey); final firstVerifier = await store.retrieve(flowId: first.flowId); final secondVerifier = await store.retrieve(flowId: second.flowId); @@ -595,6 +667,7 @@ void main() { final expectedVerifier = await PKCEVerifierStore( storage, + storageKey: client.storageKey, ).retrieve(flowId: first.flowId); await client.getSessionFromUrl( @@ -617,7 +690,7 @@ void main() { await client.signOut(); - final store = PKCEVerifierStore(storage); + final store = PKCEVerifierStore(storage, storageKey: client.storageKey); expect(await store.retrieve(flowId: first.flowId), isNull); expect(await store.retrieve(flowId: second.flowId), isNull); expect(await store.retrieve(), isNull); diff --git a/packages/supabase_auth/test/session_persistence_test.dart b/packages/supabase_auth/test/session_persistence_test.dart new file mode 100644 index 000000000..27a92a84f --- /dev/null +++ b/packages/supabase_auth/test/session_persistence_test.dart @@ -0,0 +1,405 @@ +import 'dart:convert'; + +import 'package:dotenv/dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:supabase_auth/supabase_auth.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + final env = DotEnv(); + env.load(); + + final authUrl = getAuthUrl(env); + final anonToken = getAnonToken(env); + final storageKey = defaultPersistSessionKey(authUrl); + + late TestAsyncStorage storage; + + AuthClient createClient({ + bool persistSession = true, + String? storageKey, + bool autoRefreshToken = true, + }) { + final client = AuthClient( + url: authUrl, + headers: {'Authorization': 'Bearer $anonToken', 'apikey': anonToken}, + asyncStorage: storage, + persistSession: persistSession, + storageKey: storageKey, + autoRefreshToken: autoRefreshToken, + flowType: AuthFlowType.implicit, + ); + addTearDown(client.dispose); + return client; + } + + /// Lets the queued storage writes of the client run. + Future settle() => Future.delayed(Duration.zero); + + setUp(() async { + final response = await http.post( + Uri.parse(resetAuthDataUrl), + headers: { + 'x-forwarded-for': '127.0.0.1', + 'apikey': getServiceRoleToken(env), + 'Authorization': 'Bearer ${getServiceRoleToken(env)}', + }, + ); + if (response.body.isNotEmpty) throw response.body; + storage = TestAsyncStorage(); + }); + + test('emits a null initial session when nothing is persisted', () async { + final client = createClient(); + + await client.initialized; + + final state = await client.onAuthStateChange.first; + expect(state.event, AuthChangeEvent.initialSession); + expect(state.session, isNull); + expect(client.currentSession, isNull); + }); + + test('uses the key derived from the url by default', () async { + final client = createClient(); + + expect(client.storageKey, storageKey); + expect(storageKey, startsWith('sb-')); + }); + + test('writes the session on sign in and removes it on sign out', () async { + final client = createClient(); + await client.initialized; + + final response = await client.signInWithPassword( + email: email1, + password: password, + ); + await settle(); + + final persisted = await storage.getItem(storageKey); + expect(persisted, isNotNull); + expect( + Session.fromJson(jsonDecode(persisted!))?.accessToken, + response.session?.accessToken, + ); + + await client.signOut(); + await settle(); + + expect(await storage.getItem(storageKey), isNull); + }); + + test('restores the persisted session in a new client', () async { + final client = createClient(); + await client.initialized; + final response = await client.signInWithPassword( + email: email1, + password: password, + ); + await settle(); + + final restored = createClient(); + await restored.initialized; + + expect( + restored.currentSession?.accessToken, + response.session?.accessToken, + ); + final state = await restored.onAuthStateChange.first; + expect(state.event, AuthChangeEvent.initialSession); + expect(state.session?.accessToken, response.session?.accessToken); + }); + + test('stores the session under a custom storage key', () async { + final client = createClient(storageKey: 'custom-key'); + await client.initialized; + + await client.signInWithPassword(email: email1, password: password); + await settle(); + + expect(client.storageKey, 'custom-key'); + expect(await storage.getItem('custom-key'), isNotNull); + expect(await storage.getItem(storageKey), isNull); + }); + + test('a client that does not persist leaves the storage alone', () async { + final client = createClient(persistSession: false); + await client.initialized; + + await client.signInWithPassword(email: email1, password: password); + await settle(); + + expect(await storage.getItem(storageKey), isNull); + expect(client.currentSession, isNotNull); + }); + + test('a client that does not persist emits a null initial session', () async { + final client = createClient(persistSession: false); + await client.initialized; + final states = []; + final subscription = client.onAuthStateChange.listen(states.add); + addTearDown(subscription.cancel); + + await client.signInWithPassword(email: email1, password: password); + await settle(); + + expect(states.map((state) => state.event), [ + AuthChangeEvent.initialSession, + AuthChangeEvent.signedIn, + ]); + expect(states.first.session, isNull); + }); + + test('restores an expired session and signs out when it cannot be ' + 'refreshed', () async { + final expired = getSessionData( + DateTime.now().subtract(const Duration(hours: 1)), + ); + await storage.setItem(storageKey, expired.sessionString); + final client = createClient(); + + await client.initialized; + + expect(client.currentSession?.accessToken, expired.accessToken); + expect(client.currentSession?.isExpired, isTrue); + + final states = await client.onAuthStateChange + .handleError((_) {}) + .take(2) + .toList(); + expect(states.first.event, AuthChangeEvent.initialSession); + expect(states.first.session?.accessToken, expired.accessToken); + expect(states.last.event, AuthChangeEvent.signedOut); + expect(states.last.signOutReason, SignOutReason.sessionExpired); + await settle(); + expect(await storage.getItem(storageKey), isNull); + }); + + test('does not refresh an expired session without auto refresh', () async { + final expired = getSessionData( + DateTime.now().subtract(const Duration(hours: 1)), + ); + await storage.setItem(storageKey, expired.sessionString); + final client = createClient(autoRefreshToken: false); + + await client.initialized; + + await expectLater( + client.onAuthStateChange, + emitsThrough(emitsError(isA())), + ); + expect(client.currentSession, isNull); + }); + + test('discards a persisted value that is not a session', () async { + await storage.setItem(storageKey, 'not a session'); + final client = createClient(); + + await client.initialized; + await settle(); + + expect(client.currentSession, isNull); + expect(await storage.getItem(storageKey), isNull); + final state = await client.onAuthStateChange + .handleError((_) {}) + .firstWhere( + (candidate) => candidate.event == AuthChangeEvent.initialSession, + ); + expect(state.session, isNull); + }); + + test('a session without user data is removed from the storage', () async { + await storage.setItem(storageKey, '{"access_token":"token"}'); + final client = createClient(); + + await client.initialized; + await settle(); + + expect(client.currentSession, isNull); + expect(await storage.getItem(storageKey), isNull); + }); + + test('a sign in during the restore is not replaced by the stored ' + 'session', () async { + final stored = getSessionData(DateTime.now().add(const Duration(hours: 1))); + final slowStorage = _SlowStorage(); + await slowStorage.setItem(storageKey, stored.sessionString); + final client = AuthClient( + url: authUrl, + headers: {'Authorization': 'Bearer $anonToken', 'apikey': anonToken}, + asyncStorage: slowStorage, + persistSession: true, + flowType: AuthFlowType.implicit, + ); + addTearDown(client.dispose); + final states = []; + final subscription = client.onAuthStateChange.listen(states.add); + addTearDown(subscription.cancel); + + final response = await client.signInWithPassword( + email: email1, + password: password, + ); + await client.initialized; + await settle(); + + expect(client.currentSession?.accessToken, response.session?.accessToken); + final persisted = await slowStorage.getItem(storageKey); + expect( + Session.fromJson(jsonDecode(persisted!))?.accessToken, + response.session?.accessToken, + ); + expect(states.map((state) => state.event), [ + AuthChangeEvent.initialSession, + AuthChangeEvent.signedIn, + ]); + expect( + states.first.session?.accessToken, + response.session?.accessToken, + ); + }); + + test('persists the user after it was updated', () async { + final client = createClient(); + await client.initialized; + await client.signInWithPassword(email: email1, password: password); + + await client.updateUser(UserAttributes(data: {'name': 'Updated'})); + await settle(); + + final persisted = await storage.getItem(storageKey); + final session = Session.fromJson(jsonDecode(persisted!)); + expect(session?.user.userMetadata?['name'], 'Updated'); + }); + + test('a persisted value without an access token only emits an initial ' + 'session', () async { + await storage.setItem(storageKey, '{}'); + final client = createClient(); + final events = []; + final subscription = client.onAuthStateChange.listen( + (state) => events.add(state.event), + onError: (_) {}, + ); + addTearDown(subscription.cancel); + + await client.initialized; + await settle(); + + expect(client.currentSession, isNull); + expect(await storage.getItem(storageKey), isNull); + expect(events, [AuthChangeEvent.initialSession]); + }); + + test('does not write the restored session back to the storage', () async { + final stored = getSessionData(DateTime.now().add(const Duration(hours: 1))); + final countingStorage = _CountingStorage(); + await countingStorage.setItem(storageKey, stored.sessionString); + countingStorage.writes = 0; + final client = AuthClient( + url: authUrl, + headers: {'Authorization': 'Bearer $anonToken', 'apikey': anonToken}, + asyncStorage: countingStorage, + persistSession: true, + flowType: AuthFlowType.implicit, + ); + addTearDown(client.dispose); + + await client.initialized; + await settle(); + + expect(client.currentSession?.accessToken, stored.accessToken); + expect(countingStorage.writes, 0); + }); + + test('a late subscriber receives the current session as its initial ' + 'event', () async { + final client = createClient(persistSession: false); + await client.initialized; + final response = await client.signInWithPassword( + email: email1, + password: password, + ); + + final state = await client.onAuthStateChange.first; + + expect(state.event, AuthChangeEvent.initialSession); + expect(state.session?.accessToken, response.session?.accessToken); + }); + + test('every subscriber receives its own initial event', () async { + final client = createClient(persistSession: false); + await client.initialized; + + final first = await client.onAuthStateChange.first; + final second = await client.onAuthStateChange.first; + + expect(first.event, AuthChangeEvent.initialSession); + expect(second.event, AuthChangeEvent.initialSession); + }); + + test('a failing storage does not break sign in', () async { + const failingStorage = _FailingStorage(); + final client = AuthClient( + url: authUrl, + headers: {'Authorization': 'Bearer $anonToken', 'apikey': anonToken}, + asyncStorage: failingStorage, + persistSession: true, + flowType: AuthFlowType.implicit, + ); + addTearDown(client.dispose); + await client.initialized; + + final response = await client.signInWithPassword( + email: email1, + password: password, + ); + await settle(); + + expect(client.currentSession?.accessToken, response.session?.accessToken); + }); +} + +/// Takes long enough to read that a sign in can complete in the meantime. +class _SlowStorage extends MemoryAuthAsyncStorage { + @override + Future getItem(String key) async { + await Future.delayed(const Duration(seconds: 2)); + return super.getItem(key); + } +} + +/// Counts the writes it receives. +class _CountingStorage extends MemoryAuthAsyncStorage { + int writes = 0; + + @override + Future setItem(String key, String value) { + writes++; + return super.setItem(key, value); + } + + @override + Future removeItem(String key) { + writes++; + return super.removeItem(key); + } +} + +class _FailingStorage extends AuthAsyncStorage { + const _FailingStorage(); + + @override + Future getItem(String key) async => throw StateError('read failed'); + + @override + Future setItem(String key, String value) async => + throw StateError('write failed'); + + @override + Future removeItem(String key) async => + throw StateError('remove failed'); +} diff --git a/packages/supabase_auth/test/src/constants_test.dart b/packages/supabase_auth/test/src/constants_test.dart index 1d28a9970..f4cac33d4 100644 --- a/packages/supabase_auth/test/src/constants_test.dart +++ b/packages/supabase_auth/test/src/constants_test.dart @@ -19,7 +19,7 @@ void main() { }); test('has correct default storage key', () { - expect(AuthConstants.defaultStorageKey, equals('supabase.auth.token')); + expect(AuthConstants.legacyStorageKey, equals('supabase.auth.token')); }); test('has correct expiry margin duration', () { diff --git a/packages/supabase_auth/test/src/set_session_test.dart b/packages/supabase_auth/test/src/set_session_test.dart index 150f0d8e9..7a1bdcf9f 100644 --- a/packages/supabase_auth/test/src/set_session_test.dart +++ b/packages/supabase_auth/test/src/set_session_test.dart @@ -203,7 +203,12 @@ void main() { expect( client.onAuthStateChange, - emits(predicate((s) => s.event == AuthChangeEvent.signedIn)), + emitsInOrder([ + predicate( + (s) => s.event == AuthChangeEvent.initialSession, + ), + predicate((s) => s.event == AuthChangeEvent.signedIn), + ]), ); await client.setSession('some-refresh-token', accessToken: accessToken); @@ -220,11 +225,14 @@ void main() { expect( client.onAuthStateChange, - emits( + emitsInOrder([ + predicate( + (s) => s.event == AuthChangeEvent.initialSession, + ), predicate( (s) => s.event == AuthChangeEvent.tokenRefreshed, ), - ), + ]), ); await client.setSession('some-refresh-token', accessToken: accessToken); diff --git a/packages/supabase_common/lib/src/persist_session_key.dart b/packages/supabase_common/lib/src/persist_session_key.dart index 78bb322e9..553ed4fe5 100644 --- a/packages/supabase_common/lib/src/persist_session_key.dart +++ b/packages/supabase_common/lib/src/persist_session_key.dart @@ -1,9 +1,8 @@ /// 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. +/// This is what `AuthClient.storageKey` defaults to, so it is the key to read +/// when you look for the session the SDK persisted in your own storage. /// /// The other Supabase client libraries derive the key the same way, so a /// session written by one of them is found by the others. diff --git a/packages/supabase_flutter/README.md b/packages/supabase_flutter/README.md index b20db6bde..efa94b4dc 100644 --- a/packages/supabase_flutter/README.md +++ b/packages/supabase_flutter/README.md @@ -69,7 +69,7 @@ final supabase = Supabase.instance.client; * [Storage](#storage) * [Edge Functions](#edge-functions) * [Deep Links](#deep-links) -* [Custom LocalStorage](#custom-localstorage) +* [Custom session storage](#custom-session-storage) - [Logging](#logging) @@ -496,65 +496,46 @@ Follow the guide to find additional platform specific configs for your OAuth pro https://supabase.io/docs/guides/auth#third-party-logins -## Custom LocalStorage +## Custom session storage -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. +By default, `supabase_flutter` uses the `SharedPreferencesAsync` API of [`shared_preferences`](https://pub.dev/packages/shared_preferences) to persist the user session and the code verifiers of pending PKCE flows. 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. +You can store them anywhere else by passing an `AuthAsyncStorage` implementation. For example, we can use the [`flutter_secure_storage`](https://pub.dev/packages/flutter_secure_storage) plugin to keep 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. +The session is stored under a key derived from your project URL, which `defaultPersistSessionKey` returns and `storageKey` on the auth options overrides. The code verifiers are stored under keys prefixed with it. ```dart -// Define the custom LocalStorage implementation -class MySecureStorage extends LocalStorage { - MySecureStorage({required this.persistSessionKey}); - - final String persistSessionKey; - +// Define the custom AuthAsyncStorage implementation +class MySecureStorage extends AuthAsyncStorage { final storage = FlutterSecureStorage(); @override - Future initialize() async {} - - @override - Future accessToken() async { - return storage.read(key: persistSessionKey); - } - - @override - Future hasAccessToken() async { - return storage.containsKey(key: persistSessionKey); - } + Future getItem(String key) => storage.read(key: key); @override - Future persistSession(String persistSessionString) async { - return storage.write(key: persistSessionKey, value: persistSessionString); - } + Future setItem(String key, String value) => + storage.write(key: key, value: value); @override - Future removePersistedSession() async { - return storage.delete(key: persistSessionKey); - } + Future removeItem(String key) => storage.delete(key: key); } // use it when initializing Supabase.initialize( ... authOptions: FlutterAuthClientOptions( - localStorage: MySecureStorage( - persistSessionKey: defaultPersistSessionKey(supabaseUrl), - ), + asyncStorage: MySecureStorage(), ), ); ``` -You can also use `EmptyLocalStorage` to disable session persistence: +Set `persistSession` to `false` to keep the session in memory only. The code verifiers are still stored, so a sign-in through an email link or an OAuth redirect can complete after the app was closed in between. ```dart Supabase.initialize( // ... authOptions: FlutterAuthClientOptions( - localStorage: const EmptyLocalStorage(), + persistSession: false, ), ); ``` diff --git a/packages/supabase_flutter/lib/src/flutter_auth_client_options.dart b/packages/supabase_flutter/lib/src/flutter_auth_client_options.dart index 24c77051a..0e1002667 100644 --- a/packages/supabase_flutter/lib/src/flutter_auth_client_options.dart +++ b/packages/supabase_flutter/lib/src/flutter_auth_client_options.dart @@ -1,30 +1,23 @@ import 'package:supabase_flutter/supabase_flutter.dart'; /// Configuration for the auth client used by `Supabase.instance.client.auth`, -/// extending [AuthClientOptions] with Flutter-specific session persistence -/// and deep link handling. +/// extending [AuthClientOptions] with deep link handling. +/// +/// The session is persisted by default, to shared preferences unless another +/// [asyncStorage] is passed. class FlutterAuthClientOptions extends AuthClientOptions { const FlutterAuthClientOptions({ super.authFlowType, super.autoRefreshToken, - super.pkceAsyncStorage, + super.asyncStorage, + super.persistSession = true, + super.storageKey, super.appendPkceFlowIdToRedirects, super.retryOptions, - super.persistSession = true, - this.localStorage, this.detectSessionInUri = true, this.detectSessionInUriPredicate, }); - /// Where the session is persisted. - /// - /// Defaults to shared preferences when [persistSession] is `true`, and to - /// an in-memory-only storage otherwise. A custom storage is used regardless - /// of [persistSession], and the session then counts as persisted unless the - /// storage is an [EmptyLocalStorage], so cross-tab sync on web follows the - /// storage that is actually in use. - final LocalStorage? localStorage; - /// If true, the client will start the deep link observer and obtain sessions /// when a valid URI is detected. final bool detectSessionInUri; @@ -45,26 +38,26 @@ class FlutterAuthClientOptions extends AuthClientOptions { FlutterAuthClientOptions copyWith({ AuthFlowType? authFlowType, bool? autoRefreshToken, - LocalStorage? localStorage, - AuthAsyncStorage? pkceAsyncStorage, + AuthAsyncStorage? asyncStorage, + bool? persistSession, + String? storageKey, bool? appendPkceFlowIdToRedirects, SupabaseRetryOptions? retryOptions, bool? detectSessionInUri, bool Function(Uri uri)? detectSessionInUriPredicate, - bool? persistSession, }) { return FlutterAuthClientOptions( authFlowType: authFlowType ?? this.authFlowType, autoRefreshToken: autoRefreshToken ?? this.autoRefreshToken, - localStorage: localStorage ?? this.localStorage, - pkceAsyncStorage: pkceAsyncStorage ?? this.pkceAsyncStorage, + asyncStorage: asyncStorage ?? this.asyncStorage, + persistSession: persistSession ?? this.persistSession, + storageKey: storageKey ?? this.storageKey, appendPkceFlowIdToRedirects: appendPkceFlowIdToRedirects ?? this.appendPkceFlowIdToRedirects, retryOptions: retryOptions ?? this.retryOptions, detectSessionInUri: detectSessionInUri ?? this.detectSessionInUri, detectSessionInUriPredicate: detectSessionInUriPredicate ?? this.detectSessionInUriPredicate, - persistSession: persistSession ?? this.persistSession, ); } } diff --git a/packages/supabase_flutter/lib/src/local_storage.dart b/packages/supabase_flutter/lib/src/local_storage.dart deleted file mode 100644 index 37548f890..000000000 --- a/packages/supabase_flutter/lib/src/local_storage.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:supabase_flutter/src/logger.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -import './local_storage_stub.dart' - if (dart.library.js_interop) './local_storage_web.dart' - as web; - -/// LocalStorage is used to persist the user session in the device. -/// -/// See also: -/// -/// * [SupabaseAuth], the instance used to manage authentication -/// * [EmptyLocalStorage], used to disable session persistence -/// * [SharedPreferencesLocalStorage], that implements SharedPreferencesAsync -/// as storage method -abstract class LocalStorage { - const LocalStorage(); - - /// Initialize the storage to persist session. - Future initialize(); - - /// Check if there is a persisted session. - Future hasAccessToken(); - - /// Get the access token from the current persisted session. - Future accessToken(); - - /// Remove the current persisted session. - Future removePersistedSession(); - - /// Persist a session in the device. - Future persistSession(String persistSessionString); -} - -/// A [LocalStorage] implementation that does nothing. Use this to -/// disable persistence. -class EmptyLocalStorage extends LocalStorage { - /// Creates a [LocalStorage] instance that disables persistence - const EmptyLocalStorage(); - - @override - Future initialize() async {} - - @override - Future hasAccessToken() => Future.value(false); - - @override - Future accessToken() => Future.value(); - - @override - Future removePersistedSession() async {} - - @override - Future persistSession(persistSessionString) async {} -} - -/// 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 { - SharedPreferencesLocalStorage({required this.persistSessionKey}); - late final SharedPreferencesAsync _preferences; - - /// The shared preferences key the session is stored under. - final String persistSessionKey; - static const _useWebLocalStorage = - kIsWeb && bool.fromEnvironment("dart.library.js_interop"); - - @override - Future initialize() async { - if (!_useWebLocalStorage) { - WidgetsFlutterBinding.ensureInitialized(); - _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) { - flutterLogger.warning('Could not migrate the session', error, stackTrace); - } - } - - @override - Future hasAccessToken() async { - if (_useWebLocalStorage) { - return web.hasAccessToken(persistSessionKey); - } - return _preferences.containsKey(persistSessionKey); - } - - @override - Future accessToken() async { - if (_useWebLocalStorage) { - return web.accessToken(persistSessionKey); - } - return _preferences.getString(persistSessionKey); - } - - @override - Future removePersistedSession() async { - if (_useWebLocalStorage) { - web.removePersistedSession(persistSessionKey); - } else { - await _preferences.remove(persistSessionKey); - } - } - - @override - Future persistSession(String persistSessionString) async { - if (_useWebLocalStorage) { - web.persistSession(persistSessionKey, persistSessionString); - return; - } - await _preferences.setString(persistSessionKey, persistSessionString); - } -} - -/// local storage to store pkce flow code verifier. -class SharedPreferencesAuthAsyncStorage extends AuthAsyncStorage { - SharedPreferencesAuthAsyncStorage() { - WidgetsFlutterBinding.ensureInitialized(); - } - - /// Created on first use, since the plugin it talks to is only registered - /// once the bindings are initialized. - late final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); - - @override - Future getItem({required String key}) async { - return await _preferences.getString(key) ?? await _legacyItem(key); - } - - /// 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 { - 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) { - flutterLogger.warning( - 'Could not read the legacy store', - error, - stackTrace, - ); - return null; - } - } - - @override - Future removeItem({required String key}) => _preferences.remove(key); - - @override - Future setItem({required String key, required String value}) => - _preferences.setString(key, value); -} diff --git a/packages/supabase_flutter/lib/src/local_storage_stub.dart b/packages/supabase_flutter/lib/src/local_storage_stub.dart deleted file mode 100644 index 1a7f6c202..000000000 --- a/packages/supabase_flutter/lib/src/local_storage_stub.dart +++ /dev/null @@ -1,16 +0,0 @@ -// coverage:ignore-file -import 'package:meta/meta.dart'; - -@internal -bool hasAccessToken(String _) => throw UnimplementedError(); - -@internal -// ignore: avoid-unnecessary-nullable-return-type -String? accessToken(String _) => throw UnimplementedError(); - -@internal -void removePersistedSession(String _) => throw UnimplementedError(); - -@internal -void persistSession(String _, String persistSessionString) => - throw UnimplementedError(); diff --git a/packages/supabase_flutter/lib/src/local_storage_web.dart b/packages/supabase_flutter/lib/src/local_storage_web.dart deleted file mode 100644 index 66a808e78..000000000 --- a/packages/supabase_flutter/lib/src/local_storage_web.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:web/web.dart'; -import 'package:meta/meta.dart'; - -final _localStorage = window.localStorage; - -@internal -bool hasAccessToken(String persistSessionKey) => - _localStorage.getItem(persistSessionKey) != null; - -@internal -String? accessToken(String persistSessionKey) => - _localStorage.getItem(persistSessionKey); - -@internal -void removePersistedSession(String persistSessionKey) => - _localStorage.removeItem(persistSessionKey); - -@internal -void persistSession(String persistSessionKey, String persistSessionString) => - _localStorage.setItem(persistSessionKey, persistSessionString); diff --git a/packages/supabase_flutter/lib/src/shared_preferences_auth_async_storage.dart b/packages/supabase_flutter/lib/src/shared_preferences_auth_async_storage.dart new file mode 100644 index 000000000..e0fa8b98e --- /dev/null +++ b/packages/supabase_flutter/lib/src/shared_preferences_auth_async_storage.dart @@ -0,0 +1,275 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/src/logger.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import './shared_preferences_storage_stub.dart' + if (dart.library.js_interop) './shared_preferences_storage_web.dart' + as web; + +/// The [AuthAsyncStorage] that `Supabase.initialize` uses unless another one +/// is passed, holding the session and the pkce code verifiers. +/// +/// Writes through the [SharedPreferencesAsync] API of `shared_preferences`, +/// except on web where it uses `window.localStorage` directly so the session +/// is shared with supabase-js under the same key. +/// +/// A value written by supabase_flutter v2 through the legacy +/// [SharedPreferences] API is moved over to [SharedPreferencesAsync] the first +/// time it is read. +class SharedPreferencesAuthAsyncStorage extends AuthAsyncStorage { + SharedPreferencesAuthAsyncStorage() { + WidgetsFlutterBinding.ensureInitialized(); + } + + /// Created on first use, since the plugin it talks to is only registered + /// once the bindings are initialized. + late final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); + + static const _useWebLocalStorage = + kIsWeb && bool.fromEnvironment('dart.library.js_interop'); + + /// The keys whose legacy value has been looked up in this process. Nothing + /// writes to the legacy store anymore, so a second lookup would find the + /// same thing. + final _legacyChecked = {}; + + /// The operation the next one has to wait for. + /// + /// A read that moves a legacy value over yields while it consults the legacy + /// store. Were a write allowed to run in the meantime, the migration would + /// finish by putting the legacy value over the value just written, or by + /// bringing back a session that was just signed out of. + Future _operations = Future.value(); + + Future _serialize(Future Function() operation) { + final result = _operations.then((_) => operation()); + _operations = result.then((_) {}, onError: (_) {}); + return result; + } + + @override + Future getItem(String key) => _serialize(() => _getItem(key)); + + @override + Future setItem(String key, String value) => + _serialize(() => _setItem(key, value)); + + @override + Future removeItem(String key) => _serialize(() => _removeItem(key)); + + Future _getItem(String key) async { + if (_useWebLocalStorage) { + return _webItem(key) ?? await _migrateLegacyWebItem(key); + } + return await _preferences.getString(key) ?? await _migrateLegacyItem(key); + } + + Future _setItem(String key, String value) async { + if (_useWebLocalStorage) { + web.setItem(key, value); + return; + } + await _preferences.setString(key, value); + } + + Future _removeItem(String key) async { + if (_useWebLocalStorage) { + web.removeItem(key); + await _retireLegacyWebItem(key); + return; + } + await _preferences.remove(key); + await _retireLegacyItem(key); + } + + /// Reads [key] from `window.localStorage`. + /// + /// Code verifiers used to be written through [SharedPreferencesAsync], which + /// on web JSON encodes the value under the very same key. Such a value is + /// decoded and written back as is, so the flow it belongs to can complete. + String? _webItem(String key) { + final value = web.getItem(key); + if (value == null || !value.startsWith('"')) { + return value; + } + final Object? decoded; + try { + decoded = jsonDecode(value); + } on FormatException { + return value; + } + if (decoded is! String) { + return value; + } + web.setItem(key, decoded); + return decoded; + } + + /// Moves a value written by supabase_flutter v2 through the legacy + /// [SharedPreferences] API over to the plain [key] in `window.localStorage`. + /// + /// That API keeps its values under a prefixed key, so a code verifier + /// written by v2 is not found under [key] until it is moved. Both keys live + /// in the same `window.localStorage`, so a deleted entry cannot come back + /// and no marker is needed. + Future _migrateLegacyWebItem(String key) async { + if (_legacyChecked.contains(key)) { + return null; + } + try { + final legacyPreferences = await SharedPreferences.getInstance(); + final value = legacyPreferences.getString(key); + _legacyChecked.add(key); + if (value == null) { + return null; + } + web.setItem(key, value); + await _removeLegacyItem(legacyPreferences, key); + return value; + } catch (error, stackTrace) { + flutterLogger.warning( + 'Could not read the legacy store', + error, + stackTrace, + ); + return null; + } + } + + /// Deletes the value the legacy [SharedPreferences] API holds for [key], so + /// that a later read cannot move a value over that was removed on purpose. + Future _retireLegacyWebItem(String key) async { + if (_legacyChecked.contains(key)) { + return; + } + try { + final legacyPreferences = await SharedPreferences.getInstance(); + _legacyChecked.add(key); + if (legacyPreferences.containsKey(key)) { + await _removeLegacyItem(legacyPreferences, key); + } + } catch (error, stackTrace) { + flutterLogger.warning( + 'Could not read the legacy store', + error, + stackTrace, + ); + } + } + + /// Records that the legacy value of [key] has been dealt with, so that it is + /// moved over once. + /// + /// 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. + static String _migratedKey(String key) => '$key-legacy-migrated'; + + /// Whether the legacy value of [key] has not been looked at yet, neither in + /// this process nor, going by the marker, in an earlier one. + Future _isLegacyItemPending(String key) async => + !_legacyChecked.contains(key) && + !await _preferences.containsKey(_migratedKey(key)); + + /// Moves the value the legacy [SharedPreferences] API holds for [key] over to + /// [SharedPreferencesAsync] and returns it. + /// + /// 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 value written by + /// supabase_flutter v2 is invisible to [SharedPreferencesAsync]. + /// + /// 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 _migrateLegacyItem(String key) async { + if (!await _isLegacyItemPending(key)) { + return null; + } + try { + final legacyPreferences = await SharedPreferences.getInstance(); + final value = legacyPreferences.getString(key); + if (value == null) { + _legacyChecked.add(key); + return null; + } + // The new store is written first, so that an interruption before the + // legacy entry is gone leaves the value in one store or the other rather + // than in neither. + await _preferences.setString(key, value); + await _preferences.setBool(_migratedKey(key), true); + _legacyChecked.add(key); + await _removeLegacyItem(legacyPreferences, key); + return value; + } catch (error, stackTrace) { + flutterLogger.warning( + 'Could not read the legacy store', + error, + stackTrace, + ); + return null; + } + } + + /// Makes sure a legacy value for [key] is not moved over after the value + /// was removed, which would bring back a session the user signed out of. + /// + /// The marker is only written when the legacy store holds the key, so the + /// keys of pkce flows that never existed in v2 leave no trace behind. When + /// the legacy store cannot be read it is written regardless: not knowing + /// whether a stale session is in there must not let one come back later. + Future _retireLegacyItem(String key) async { + if (!await _isLegacyItemPending(key)) { + return; + } + final SharedPreferences legacyPreferences; + try { + legacyPreferences = await SharedPreferences.getInstance(); + } catch (error, stackTrace) { + flutterLogger.warning( + 'Could not read the legacy store', + error, + stackTrace, + ); + await _preferences.setBool(_migratedKey(key), true); + _legacyChecked.add(key); + return; + } + _legacyChecked.add(key); + if (!legacyPreferences.containsKey(key)) { + return; + } + await _preferences.setBool(_migratedKey(key), true); + await _removeLegacyItem(legacyPreferences, key); + } + + /// Deletes the legacy entry so a stale token is not left lying around. + /// + /// Picks up what was written through the other API first. 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 value with it. + /// + /// A failure to delete only costs a leftover entry, since the value has + /// been written to the new store and marked as moved by then. + Future _removeLegacyItem( + SharedPreferences legacyPreferences, + String key, + ) async { + try { + await legacyPreferences.reload(); + await legacyPreferences.remove(key); + } catch (error, stackTrace) { + flutterLogger.warning( + 'Could not delete the legacy entry', + error, + stackTrace, + ); + } + } +} diff --git a/packages/supabase_flutter/lib/src/shared_preferences_storage_stub.dart b/packages/supabase_flutter/lib/src/shared_preferences_storage_stub.dart new file mode 100644 index 000000000..f59145504 --- /dev/null +++ b/packages/supabase_flutter/lib/src/shared_preferences_storage_stub.dart @@ -0,0 +1,12 @@ +// coverage:ignore-file +import 'package:meta/meta.dart'; + +@internal +// ignore: avoid-unnecessary-nullable-return-type +String? getItem(String _) => throw UnimplementedError(); + +@internal +void setItem(String _, String value) => throw UnimplementedError(); + +@internal +void removeItem(String _) => throw UnimplementedError(); diff --git a/packages/supabase_flutter/lib/src/shared_preferences_storage_web.dart b/packages/supabase_flutter/lib/src/shared_preferences_storage_web.dart new file mode 100644 index 000000000..242595a57 --- /dev/null +++ b/packages/supabase_flutter/lib/src/shared_preferences_storage_web.dart @@ -0,0 +1,13 @@ +import 'package:meta/meta.dart'; +import 'package:web/web.dart'; + +final _localStorage = window.localStorage; + +@internal +String? getItem(String key) => _localStorage.getItem(key); + +@internal +void setItem(String key, String value) => _localStorage.setItem(key, value); + +@internal +void removeItem(String key) => _localStorage.removeItem(key); diff --git a/packages/supabase_flutter/lib/src/supabase.dart b/packages/supabase_flutter/lib/src/supabase.dart index 7b1da199f..6d9c964f9 100644 --- a/packages/supabase_flutter/lib/src/supabase.dart +++ b/packages/supabase_flutter/lib/src/supabase.dart @@ -1,14 +1,12 @@ import 'dart:async'; -import 'package:async/async.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart'; import 'package:supabase/supabase.dart'; -import 'package:supabase_common/supabase_common.dart'; import 'package:supabase_flutter/src/supabase_flutter_constants.dart'; import 'package:supabase_flutter/src/flutter_auth_client_options.dart'; -import 'package:supabase_flutter/src/local_storage.dart'; +import 'package:supabase_flutter/src/shared_preferences_auth_async_storage.dart'; import 'package:supabase_flutter/src/logger.dart'; import 'package:supabase_flutter/src/supabase_auth.dart'; @@ -73,18 +71,16 @@ class Supabase { /// `storageOptions.retryOptions` configures how an upload to Supabase /// storage that failed due to a network interruption is retried. /// - /// [authOptions] configures authentication behavior. Pass a custom - /// [FlutterAuthClientOptions.localStorage] there to override the default - /// local storage option used to persist auth. + /// [authOptions] configures authentication behavior. The session and the + /// pkce code verifiers are stored in shared preferences by default. Pass a + /// custom [AuthClientOptions.asyncStorage] there to store them elsewhere, or + /// set [AuthClientOptions.persistSession] to false to keep the session in + /// memory only. /// /// Set [AuthClientOptions.authFlowType] on [authOptions] to /// [AuthFlowType.implicit] to use the old implicit flow for authentication /// involving deep links. /// - /// PKCE flow uses shared preferences for storing the code verifier by - /// default. Pass a custom storage to [AuthClientOptions.pkceAsyncStorage] - /// on [authOptions] to override the behavior. - /// /// All Supabase packages log through `package:logging` using loggers under /// the `supabase` hierarchy (for example `supabase.auth` or /// `supabase.realtime`). Nothing is printed by default; attach a listener @@ -113,23 +109,11 @@ class Supabase { flutterLogger.config('Initialize Supabase v$version'); - if (authOptions.pkceAsyncStorage == null) { - authOptions = authOptions.copyWith( - pkceAsyncStorage: SharedPreferencesAuthAsyncStorage(), - ); - } - if (authOptions.localStorage == null) { + if (authOptions.asyncStorage == null) { authOptions = authOptions.copyWith( - localStorage: authOptions.persistSession - ? SharedPreferencesLocalStorage( - persistSessionKey: defaultPersistSessionKey(url), - ) - : const EmptyLocalStorage(), + asyncStorage: SharedPreferencesAuthAsyncStorage(), ); } - authOptions = authOptions.copyWith( - persistSession: authOptions.localStorage is! EmptyLocalStorage, - ); _instance._init( url, publishableKey, @@ -148,12 +132,6 @@ class Supabase { final supabaseAuth = SupabaseAuth(); _instance._supabaseAuth = supabaseAuth; await supabaseAuth.initialize(options: authOptions); - - // Wrap `recoverSession()` in a `CancelableOperation` so that it can be - // canceled in dispose - // if still in progress - _instance._restoreSessionCancellableOperation = - CancelableOperation.fromFuture(supabaseAuth.recoverSession()); } flutterLogger.info('Supabase initialization completed'); @@ -187,13 +165,6 @@ class Supabase { SupabaseAuth? _supabaseAuth; - /// Wraps the `recoverSession()` call so that it can be terminated when - /// `dispose()` is called - /// - /// Only set when [Supabase.initialize] is called without a custom - /// `accessToken`, since session recovery is skipped for third-party auth. - CancelableOperation? _restoreSessionCancellableOperation; - // Listener for app lifecycle events to handle Realtime reconnection. AppLifecycleListener? _lifecycleListener; @@ -216,12 +187,10 @@ class Supabase { final supabaseAuth = _supabaseAuth; final lifecycleListener = _lifecycleListener; - final restoreSession = _restoreSessionCancellableOperation; final pendingLifecycleOperation = _pendingLifecycleOperation; _client = null; _supabaseAuth = null; - _restoreSessionCancellableOperation = null; _lifecycleListener = null; _isInitialized = false; @@ -232,7 +201,6 @@ class Supabase { // lifecycle event cannot reach a client that is already torn down. await _disposeAll([ () => supabaseAuth?.dispose(), - () => restoreSession?.cancel(), () => pendingLifecycleOperation, currentClient.dispose, ]); diff --git a/packages/supabase_flutter/lib/src/supabase_auth.dart b/packages/supabase_flutter/lib/src/supabase_auth.dart index bbbc64c46..fbc40f461 100644 --- a/packages/supabase_flutter/lib/src/supabase_auth.dart +++ b/packages/supabase_flutter/lib/src/supabase_auth.dart @@ -23,8 +23,9 @@ import 'clear_auth_url_parameters_stub.dart' /// `Supabase.instance.client.auth` for auth operations. /// /// **Responsibilities:** -/// - Persists and restores sessions via a [LocalStorage] implementation so -/// that users remain signed in across app restarts. +/// - Waits for the [AuthClient] to restore the persisted session before the +/// deep link observer starts, so a link cannot be exchanged over a session +/// that is still being read. /// - Observes deep links (universal links / custom URL schemes) and exchanges /// auth codes or tokens found in those links for a valid session, supporting /// both PKCE and Implicit OAuth flows. @@ -32,21 +33,18 @@ import 'clear_auth_url_parameters_stub.dart' /// `WidgetsBindingObserver`) to the auth client so that token refresh /// resumes correctly after the app /// returns to the foreground. -/// - Emits an [AuthChangeEvent.initialSession] event at startup so that -/// listeners receive a consistent first event regardless of whether a stored -/// session exists. /// /// **Key collaborators:** /// - [AuthClient] (`Supabase.instance.client.auth`) — the underlying auth /// client that [SupabaseAuth] coordinates with. -/// - [LocalStorage] — pluggable storage backend for session persistence. /// - `AppLinks` — provides the incoming deep link stream and the initial link /// that launched the app. /// /// **Lifecycle:** /// 1. Created lazily when [Supabase.initialize] runs. -/// 2. [initialize] restores any persisted session, registers the deep link -/// observer, and adds this instance as a `WidgetsBindingObserver`. +/// 2. [initialize] waits for the persisted session to be restored, registers +/// the deep link observer, and adds this instance as a +/// `WidgetsBindingObserver`. /// 3. [dispose] cancels all subscriptions, removes the binding observer, and /// stops deep link monitoring. /// @@ -59,8 +57,6 @@ import 'clear_auth_url_parameters_stub.dart' class SupabaseAuth with WidgetsBindingObserver { static WidgetsBinding get _widgetsBindingInstance => WidgetsBinding.instance; - late LocalStorage _localStorage; - /// Whether to automatically refresh the token late bool _autoRefreshToken; @@ -73,8 +69,6 @@ class SupabaseAuth with WidgetsBindingObserver { /// throughout your app's life. static bool _initialDeeplinkIsHandled = false; - StreamSubscription? _authSubscription; - StreamSubscription? _deeplinkSubscription; final _appLinks = AppLinks(); @@ -83,88 +77,20 @@ class SupabaseAuth with WidgetsBindingObserver { /// teardown does not touch the disposed [Supabase] instance. bool _isDisposed = false; - /// - Obtains session from local storage and sets it as the current session + /// - Waits for the auth client to restore the persisted session /// - Starts a deep link observer - /// - Emits an initial session if there were no session stored in local - /// storage - /// - /// Errors emitted by the auth state change stream (e.g. during token refresh - /// or network failures) are logged by the underlying auth client and do not - /// propagate as unhandled zone errors. Future initialize({ required FlutterAuthClientOptions options, }) async { - _localStorage = options.localStorage!; _autoRefreshToken = options.autoRefreshToken; _detectSessionInUriPredicate = options.detectSessionInUriPredicate; - _authSubscription = Supabase.instance.client.auth.onAuthStateChange.listen( - (data) { - unawaited(_onAuthStateChange(data.event, data.session)); - }, - onError: (error, stackTrace) { - // Errors are already logged by AuthClient.notifyException before - // being added to the stream. The empty handler prevents them from - // being rethrown as unhandled zone errors. - }, - ); - - await _localStorage.initialize(); - - final hasPersistedSession = await _localStorage.hasAccessToken(); - var shouldEmitInitialSession = true; - if (hasPersistedSession) { - final persistedSession = await _localStorage.accessToken(); - if (persistedSession != null) { - try { - await Supabase.instance.client.auth.setInitialSession( - persistedSession, - ); - shouldEmitInitialSession = false; - } catch (error, stackTrace) { - flutterLogger.warning( - 'Error while setting initial session', - error, - stackTrace, - ); - } - } - } - if (shouldEmitInitialSession) { - Supabase.instance.client.auth - // ignore: invalid_use_of_internal_member - .notifyAllSubscribers(AuthChangeEvent.initialSession); - } + await Supabase.instance.client.auth.initialized; _widgetsBindingInstance.addObserver(this); if (options.detectSessionInUri) { await _startDeeplinkObserver(); } - - // Emit a null session if the user did not have persisted session - } - - /// Recovers the session from local storage. - /// - /// Called lazily after `.initialize()` by `Supabase` instance - Future recoverSession() async { - try { - final hasPersistedSession = await _localStorage.hasAccessToken(); - if (hasPersistedSession) { - final persistedSession = await _localStorage.accessToken(); - if (persistedSession != null) { - await Supabase.instance.client.auth.recoverSession(persistedSession); - } - } - } on AuthException catch (error, stackTrace) { - flutterLogger.warning(error.message, error, stackTrace); - } catch (error, stackTrace) { - flutterLogger.warning( - "Error while recovering session", - error, - stackTrace, - ); - } } /// Dispose the instance to free up resources @@ -173,7 +99,6 @@ class SupabaseAuth with WidgetsBindingObserver { if (isRunningInFlutterTest) { _initialDeeplinkIsHandled = false; } - unawaited(_authSubscription?.cancel()); _stopDeeplinkObserver(); _widgetsBindingInstance.removeObserver(this); } @@ -201,25 +126,6 @@ class SupabaseAuth with WidgetsBindingObserver { } } - Future _onAuthStateChange( - AuthChangeEvent event, - Session? session, - ) async { - try { - if (session != null) { - await _localStorage.persistSession(jsonEncode(session.toJson())); - } else if (event == AuthChangeEvent.signedOut) { - await _localStorage.removePersistedSession(); - } - } catch (error, stackTrace) { - flutterLogger.warning( - 'Error while persisting auth state change', - error, - stackTrace, - ); - } - } - /// Decides whether an incoming deep link should be exchanged for a session. /// /// Uses the custom predicate supplied via diff --git a/packages/supabase_flutter/lib/supabase_flutter.dart b/packages/supabase_flutter/lib/supabase_flutter.dart index 211a58768..c53288fb8 100644 --- a/packages/supabase_flutter/lib/supabase_flutter.dart +++ b/packages/supabase_flutter/lib/supabase_flutter.dart @@ -7,7 +7,7 @@ export 'package:supabase_common/supabase_common.dart' export 'package:url_launcher/url_launcher.dart' show LaunchMode; export 'src/flutter_auth_client_options.dart'; -export 'src/local_storage.dart'; +export 'src/shared_preferences_auth_async_storage.dart'; export 'src/supabase.dart'; export 'src/supabase_auth.dart' hide SupabaseAuth; export 'src/supabase_passkey.dart'; diff --git a/packages/supabase_flutter/lib/testing.dart b/packages/supabase_flutter/lib/testing.dart index 29585c502..097ea2d8e 100644 --- a/packages/supabase_flutter/lib/testing.dart +++ b/packages/supabase_flutter/lib/testing.dart @@ -42,9 +42,9 @@ final String testPublishableKey = _unsignedJwt({ /// Initializes [Supabase] for a test and returns the instance. /// -/// Compared to [Supabase.initialize], this keeps the session in memory -/// through [localStorage], which defaults to [EmptyLocalStorage], stores the -/// pkce code verifier in memory, turns off the token auto refresh so no timer +/// Compared to [Supabase.initialize], this keeps the session and the pkce +/// code verifier in memory through [asyncStorage], which defaults to a fresh +/// [MemoryAuthAsyncStorage], turns off the token auto refresh so no timer /// outlives the test, disables deep link detection so no platform channel is /// touched, and defaults [publishableKey] to [testPublishableKey]. Pass a /// `MockSupabaseHttpClient` as [httpClient] to answer the requests, and the @@ -60,7 +60,7 @@ Future initializeTestSupabase({ String? publishableKey, Map? headers, WebSocketTransport? realtimeTransport, - LocalStorage? localStorage, + AuthAsyncStorage? asyncStorage, bool autoRefreshToken = false, }) { return Supabase.initialize( @@ -71,8 +71,7 @@ Future initializeTestSupabase({ realtimeClientOptions: RealtimeClientOptions(transport: realtimeTransport), authOptions: FlutterAuthClientOptions( autoRefreshToken: autoRefreshToken, - localStorage: localStorage ?? const EmptyLocalStorage(), - pkceAsyncStorage: MemoryAuthAsyncStorage(), + asyncStorage: asyncStorage ?? MemoryAuthAsyncStorage(), detectSessionInUri: false, ), ); diff --git a/packages/supabase_flutter/pubspec.yaml b/packages/supabase_flutter/pubspec.yaml index c6deca8bc..b84259b7a 100644 --- a/packages/supabase_flutter/pubspec.yaml +++ b/packages/supabase_flutter/pubspec.yaml @@ -20,7 +20,6 @@ resolution: workspace dependencies: app_links: '>=6.4.1 <8.0.0' - async: ^2.12.0 flutter: sdk: flutter http: ^1.6.0 diff --git a/packages/supabase_flutter/test/auth_test.dart b/packages/supabase_flutter/test/auth_test.dart index 23c7b3687..02d07d661 100644 --- a/packages/supabase_flutter/test/auth_test.dart +++ b/packages/supabase_flutter/test/auth_test.dart @@ -3,15 +3,13 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'widget_test_stubs.dart'; -class _MockLocalStorage extends MockLocalStorage { - bool _initializeCalled = false; - - bool get initializeCalled => _initializeCalled; +class _RecordingStorage extends MockAsyncStorage { + final readKeys = []; @override - Future initialize() { - _initializeCalled = true; - return super.initialize(); + Future getItem(String key) { + readKeys.add(key); + return super.getItem(key); } } @@ -41,23 +39,35 @@ void main() { }); group('Session management', () { - test('initializes local storage on initialize', () async { - final mockStorage = _MockLocalStorage(); + test('reads the persisted session on initialize', () async { + final mockStorage = _RecordingStorage(); await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, - authOptions: FlutterAuthClientOptions( - localStorage: mockStorage, - pkceAsyncStorage: MockAsyncStorage(), - ), + authOptions: FlutterAuthClientOptions(asyncStorage: mockStorage), ); - // Give time for initialization to complete - await Future.delayed(const Duration(milliseconds: 100)); - - expect(mockStorage.initializeCalled, isTrue); + expect(mockStorage.readKeys, [defaultPersistSessionKey(supabaseUrl)]); }); + + test( + 'does not read the storage when the session is not persisted', + () async { + final mockStorage = _RecordingStorage(); + + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + authOptions: FlutterAuthClientOptions( + asyncStorage: mockStorage, + persistSession: false, + ), + ); + + expect(mockStorage.readKeys, isEmpty); + }, + ); }); group('Auth state stream error handling', () { @@ -68,8 +78,7 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), ), ); @@ -93,32 +102,27 @@ void main() { }); group('Session recovery', () { - test('handles corrupted session data gracefully', () async { - const corruptedStorage = MockExpiredStorage(); - + test('restores an expired session', () async { await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: corruptedStorage, - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().subtract(const Duration(hours: 1)), + ), ), ); - // MockExpiredStorage returns an expired session, not null expect(Supabase.instance.client.auth.currentSession, isNotNull); expect(Supabase.instance.client.auth.currentSession?.isExpired, isTrue); }); test('handles null session during initialization', () async { - const emptyStorage = MockEmptyLocalStorage(); - await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: emptyStorage, - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), ), ); diff --git a/packages/supabase_flutter/test/deep_link_test.dart b/packages/supabase_flutter/test/deep_link_test.dart index a0b2f7620..ef1f7b139 100644 --- a/packages/supabase_flutter/test/deep_link_test.dart +++ b/packages/supabase_flutter/test/deep_link_test.dart @@ -30,18 +30,17 @@ void main() { mockEventChannel: true, initialLink: 'com.supabase://callback/?code=my-code-verifier', ); - final pkceAsyncStorage = MockAsyncStorage(); - await pkceAsyncStorage.setItem( - key: 'supabase.auth.token-code-verifier', - value: 'raw-code-verifier', + final asyncStorage = MockAsyncStorage(); + await asyncStorage.setItem( + '${defaultPersistSessionKey(supabaseUrl)}-code-verifier', + 'raw-code-verifier', ); await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, httpClient: pkceHttpClient, authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: pkceAsyncStorage, + asyncStorage: asyncStorage, ), ); }); @@ -82,8 +81,7 @@ void main() { publishableKey: supabaseKey, httpClient: getUserHttpClient, authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), ), ); @@ -119,18 +117,17 @@ void main() { mockEventChannel: true, initialLink: 'com.supabase://callback/?code=my-code-verifier', ); - final pkceAsyncStorage = MockAsyncStorage(); - await pkceAsyncStorage.setItem( - key: 'supabase.auth.token-code-verifier', - value: 'raw-code-verifier', + final asyncStorage = MockAsyncStorage(); + await asyncStorage.setItem( + '${defaultPersistSessionKey(supabaseUrl)}-code-verifier', + 'raw-code-verifier', ); await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, httpClient: pkceHttpClient, authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: pkceAsyncStorage, + asyncStorage: asyncStorage, detectSessionInUriPredicate: (uri) => false, ), ); @@ -151,18 +148,17 @@ void main() { mockEventChannel: true, initialLink: 'com.supabase://callback/?code=my-code-verifier', ); - final pkceAsyncStorage = MockAsyncStorage(); - await pkceAsyncStorage.setItem( - key: 'supabase.auth.token-code-verifier', - value: 'raw-code-verifier', + final asyncStorage = MockAsyncStorage(); + await asyncStorage.setItem( + '${defaultPersistSessionKey(supabaseUrl)}-code-verifier', + 'raw-code-verifier', ); await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, httpClient: pkceHttpClient, authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: pkceAsyncStorage, + asyncStorage: asyncStorage, detectSessionInUriPredicate: (uri) { receivedUris.add(uri); return uri.queryParameters.containsKey('code'); @@ -196,25 +192,22 @@ void main() { mockEventChannel: true, initialLink: 'com.supabase://callback/?code=my-code-verifier', ); - final pkceAsyncStorage = MockAsyncStorage(); - await pkceAsyncStorage.setItem( - key: 'supabase.auth.token-code-verifier', - value: 'raw-code-verifier', + final preferences = SharedPreferencesAsync(); + await preferences.setString( + '$persistSessionKey-code-verifier', + 'raw-code-verifier', ); await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, httpClient: pkceHttpClient, - authOptions: FlutterAuthClientOptions( - pkceAsyncStorage: pkceAsyncStorage, - ), ); await Supabase.instance.client.auth.onAuthStateChange .firstWhere((state) => state.event == AuthChangeEvent.signedIn) .timeout(const Duration(seconds: 5)); + await pumpEventQueue(); - final preferences = SharedPreferencesAsync(); expect(await preferences.getString(persistSessionKey), isNotNull); }, ); @@ -230,27 +223,25 @@ void main() { mockEventChannel: true, initialLink: 'com.supabase://callback/?code=my-code-verifier', ); - final pkceAsyncStorage = MockAsyncStorage(); - await pkceAsyncStorage.setItem( - key: 'supabase.auth.token-code-verifier', - value: 'raw-code-verifier', + final preferences = SharedPreferencesAsync(); + await preferences.setString( + '$persistSessionKey-code-verifier', + 'raw-code-verifier', ); await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, httpClient: pkceHttpClient, - authOptions: FlutterAuthClientOptions( - pkceAsyncStorage: pkceAsyncStorage, - persistSession: false, - ), + authOptions: const FlutterAuthClientOptions(persistSession: false), ); await Supabase.instance.client.auth.onAuthStateChange .firstWhere((state) => state.event == AuthChangeEvent.signedIn) .timeout(const Duration(seconds: 5)); + await pumpEventQueue(); - final preferences = SharedPreferencesAsync(); expect(await preferences.getString(persistSessionKey), isNull); + expect(Supabase.instance.client.auth.currentSession, isNotNull); }, ); }); @@ -272,8 +263,7 @@ void main() { publishableKey: supabaseKey, httpClient: createGetUserHttpClient('new@email.com'), authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), ), ); diff --git a/packages/supabase_flutter/test/initialization_test.dart b/packages/supabase_flutter/test/initialization_test.dart index d97078870..01d2ef6de 100644 --- a/packages/supabase_flutter/test/initialization_test.dart +++ b/packages/supabase_flutter/test/initialization_test.dart @@ -74,14 +74,15 @@ void main() { }); group('Custom storage initialization', () { - test('initialize successfully with custom localStorage', () { - const localStorage = MockLocalStorage(); + test('initialize successfully with a custom storage', () { expect( Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, - authOptions: const FlutterAuthClientOptions( - localStorage: localStorage, + authOptions: FlutterAuthClientOptions( + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().add(const Duration(hours: 1)), + ), ), ), completes, @@ -93,8 +94,9 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockExpiredStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().subtract(const Duration(hours: 1)), + ), ), ); @@ -196,8 +198,9 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().add(const Duration(hours: 1)), + ), ), ); @@ -211,8 +214,7 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), ), ); diff --git a/packages/supabase_flutter/test/lifecycle_after_dispose_test.dart b/packages/supabase_flutter/test/lifecycle_after_dispose_test.dart index d6c9e34bf..d29aaec13 100644 --- a/packages/supabase_flutter/test/lifecycle_after_dispose_test.dart +++ b/packages/supabase_flutter/test/lifecycle_after_dispose_test.dart @@ -17,8 +17,7 @@ void main() { url: '', publishableKey: '', authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), ), ); }); diff --git a/packages/supabase_flutter/test/lifecycle_test.dart b/packages/supabase_flutter/test/lifecycle_test.dart index 55b8149da..c40334034 100644 --- a/packages/supabase_flutter/test/lifecycle_test.dart +++ b/packages/supabase_flutter/test/lifecycle_test.dart @@ -82,8 +82,7 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), ), realtimeClientOptions: RealtimeClientOptions( transport: (url, headers) { diff --git a/packages/supabase_flutter/test/local_storage_migration_test.dart b/packages/supabase_flutter/test/local_storage_migration_test.dart deleted file mode 100644 index d65b469a0..000000000 --- a/packages/supabase_flutter/test/local_storage_migration_test.dart +++ /dev/null @@ -1,239 +0,0 @@ -@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('SharedPreferencesAuthAsyncStorage 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 = SharedPreferencesAuthAsyncStorage(); - - 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 = SharedPreferencesAuthAsyncStorage(); - 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 = SharedPreferencesAuthAsyncStorage(); - - 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/logging_test.dart b/packages/supabase_flutter/test/logging_test.dart index 629c2b9e4..4f1ca252b 100644 --- a/packages/supabase_flutter/test/logging_test.dart +++ b/packages/supabase_flutter/test/logging_test.dart @@ -41,8 +41,9 @@ void main() { url: '', publishableKey: '', authOptions: FlutterAuthClientOptions( - localStorage: const MockLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().add(const Duration(hours: 1)), + ), ), ); } diff --git a/packages/supabase_flutter/test/persist_session_broadcast_test.dart b/packages/supabase_flutter/test/persist_session_broadcast_test.dart index eec49897a..c7644728c 100644 --- a/packages/supabase_flutter/test/persist_session_broadcast_test.dart +++ b/packages/supabase_flutter/test/persist_session_broadcast_test.dart @@ -15,15 +15,13 @@ void main() { tearDown(() => Supabase.instance.dispose()); - Future initializeApp({ - LocalStorage localStorage = const MockEmptyLocalStorage(), - }) async { + Future initializeApp({bool persistSession = true}) async { await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: localStorage, - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), + persistSession: persistSession, detectSessionInUri: false, ), ); @@ -35,7 +33,7 @@ void main() { supabaseUrl, key, authOptions: AuthClientOptions( - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), persistSession: persistSession, ), ); @@ -87,9 +85,7 @@ void main() { }); test('an app that keeps the session in memory does not broadcast', () async { - final appAuth = await initializeApp( - localStorage: const EmptyLocalStorage(), - ); + final appAuth = await initializeApp(persistSession: false); final otherClient = createClient(supabaseKey, persistSession: true); final broadcasts = await collectBroadcasts( diff --git a/packages/supabase_flutter/test/storage_migration_test.dart b/packages/supabase_flutter/test/storage_migration_test.dart new file mode 100644 index 000000000..5a4c83ce3 --- /dev/null +++ b/packages/supabase_flutter/test/storage_migration_test.dart @@ -0,0 +1,287 @@ +@TestOn('!browser') +/// Tests for the migration of values written by supabase_flutter v2 through +/// the legacy [SharedPreferences] API 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('SharedPreferencesAuthAsyncStorage migration of a v2 session', () { + const sessionKey = 'sb-test-auth-token'; + const testSessionValue = '{"key": "value"}'; + + test('moves a session written by the legacy API over', () async { + mockSharedPreferences(legacyValues: {sessionKey: testSessionValue}); + final storage = SharedPreferencesAuthAsyncStorage(); + + expect(await storage.getItem(sessionKey), testSessionValue); + expect( + await SharedPreferencesAsync().getString(sessionKey), + testSessionValue, + ); + }); + + test('removes the session from the legacy store', () async { + mockSharedPreferences(legacyValues: {sessionKey: testSessionValue}); + final storage = SharedPreferencesAuthAsyncStorage(); + + await storage.getItem(sessionKey); + + final legacyPreferences = await SharedPreferences.getInstance(); + expect(legacyPreferences.getString(sessionKey), isNull); + }); + + test('keeps the session of the new store when both have one', () async { + mockSharedPreferences(legacyValues: {sessionKey: '{"key": "legacy"}'}); + await SharedPreferencesAsync().setString(sessionKey, testSessionValue); + final storage = SharedPreferencesAuthAsyncStorage(); + + expect(await storage.getItem(sessionKey), testSessionValue); + }); + + test('does not restore a session that was signed out of', () async { + mockSharedPreferences(legacyValues: {sessionKey: testSessionValue}); + final storage = SharedPreferencesAuthAsyncStorage(); + await storage.getItem(sessionKey); + await storage.removeItem(sessionKey); + + // A restart of the app, which reads the storage again. + final newStorage = SharedPreferencesAuthAsyncStorage(); + + expect(await newStorage.getItem(sessionKey), isNull); + }); + + test( + 'does not restore a signed-out session when both stores had one', + () async { + mockSharedPreferences( + legacyValues: {sessionKey: '{"key": "legacy"}'}, + ); + await SharedPreferencesAsync().setString(sessionKey, testSessionValue); + final storage = SharedPreferencesAuthAsyncStorage(); + await storage.getItem(sessionKey); + await storage.removeItem(sessionKey); + + // A restart of the app, which reads the storage again. + final newStorage = SharedPreferencesAuthAsyncStorage(); + + expect(await newStorage.getItem(sessionKey), isNull); + final legacyPreferences = await SharedPreferences.getInstance(); + expect(legacyPreferences.getString(sessionKey), isNull); + }, + ); + + test('runs once, so a resurrected legacy entry is ignored', () async { + mockSharedPreferences(legacyValues: {sessionKey: testSessionValue}); + final storage = SharedPreferencesAuthAsyncStorage(); + await storage.getItem(sessionKey); + await storage.removeItem(sessionKey); + + // 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(sessionKey, testSessionValue); + + final newStorage = SharedPreferencesAuthAsyncStorage(); + + expect(await newStorage.getItem(sessionKey), isNull); + }); + + test('keeps the session when the legacy entry cannot be deleted', () async { + mockSharedPreferences(); + SharedPreferencesStorePlatform.instance = _ReadOnlyLegacyStore({ + 'flutter.$sessionKey': testSessionValue, + }); + final storage = SharedPreferencesAuthAsyncStorage(); + + // The new store is written before the legacy entry is deleted, so a + // failure to delete costs a leftover entry rather than the session. + expect(await storage.getItem(sessionKey), testSessionValue); + expect( + await SharedPreferencesAsync().getString(sessionKey), + testSessionValue, + ); + }); + + test('works even when the legacy store cannot be read', () async { + mockSharedPreferences(); + SharedPreferencesStorePlatform.instance = _ThrowingLegacyStore(); + final storage = SharedPreferencesAuthAsyncStorage(); + + expect(await storage.getItem(sessionKey), isNull); + await storage.setItem(sessionKey, testSessionValue); + expect(await storage.getItem(sessionKey), testSessionValue); + await expectLater(storage.removeItem(sessionKey), completes); + }); + + test( + 'does not restore a legacy session after a sign-out during which the ' + 'legacy store could not be read', + () async { + mockSharedPreferences(); + SharedPreferencesStorePlatform.instance = _ThrowingLegacyStore(); + final storage = SharedPreferencesAuthAsyncStorage(); + await storage.setItem(sessionKey, testSessionValue); + await storage.removeItem(sessionKey); + + // The legacy store is readable again and still holds a v2 session. + SharedPreferences.setMockInitialValues({sessionKey: testSessionValue}); + final newStorage = SharedPreferencesAuthAsyncStorage(); + + expect(await newStorage.getItem(sessionKey), isNull); + }, + ); + }); + + group('SharedPreferencesAuthAsyncStorage operation order', () { + const sessionKey = 'sb-test-auth-token'; + + test( + 'a write during a legacy migration wins over the legacy value', + () async { + mockSharedPreferences(); + SharedPreferencesStorePlatform.instance = _SlowLegacyStore({ + 'flutter.$sessionKey': '{"key": "legacy"}', + }); + final storage = SharedPreferencesAuthAsyncStorage(); + + final read = storage.getItem(sessionKey); + await storage.setItem(sessionKey, '{"key": "new"}'); + + expect(await read, '{"key": "legacy"}'); + expect(await storage.getItem(sessionKey), '{"key": "new"}'); + }, + ); + + test('a removal during a legacy migration is not undone', () async { + mockSharedPreferences(); + SharedPreferencesStorePlatform.instance = _SlowLegacyStore({ + 'flutter.$sessionKey': '{"key": "legacy"}', + }); + final storage = SharedPreferencesAuthAsyncStorage(); + + final read = storage.getItem(sessionKey); + await storage.removeItem(sessionKey); + + expect(await read, '{"key": "legacy"}'); + expect(await storage.getItem(sessionKey), isNull); + }); + }); + + group('SharedPreferencesAuthAsyncStorage migration of a v2 verifier', () { + 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 = SharedPreferencesAuthAsyncStorage(); + + expect(await storage.getItem(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 = SharedPreferencesAuthAsyncStorage(); + expect(await storage.getItem(codeVerifierKey), codeVerifier); + + await storage.removeItem(codeVerifierKey); + + expect(await storage.getItem(codeVerifierKey), isNull); + }); + + test('leaves no trace for a key the legacy store never had', () async { + mockSharedPreferences(); + final storage = SharedPreferencesAuthAsyncStorage(); + + await storage.getItem(codeVerifierKey); + await storage.removeItem(codeVerifierKey); + + expect(await SharedPreferencesAsync().getKeys(), isEmpty); + }); + }); +} + +/// Stands in for a legacy store that takes a while to answer, so that another +/// operation can be started while a migration is still reading it. +class _SlowLegacyStore extends SharedPreferencesStorePlatform { + _SlowLegacyStore(this._data); + + final Map _data; + + @override + Future clear() => throw UnimplementedError(); + + @override + Future> getAll() async { + await Future.delayed(const Duration(milliseconds: 50)); + return Map.of(_data); + } + + @override + Future remove(String key) async { + _data.remove(key); + return true; + } + + @override + Future setValue(String valueType, String key, Object value) async { + _data[key] = value; + return true; + } +} + +/// 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 b03649327..e11f16311 100644 --- a/packages/supabase_flutter/test/storage_test.dart +++ b/packages/supabase_flutter/test/storage_test.dart @@ -7,121 +7,51 @@ import 'utils.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - group('Storage Tests', () { - // SharedPreferencesLocalStorage Tests - group('SharedPreferencesLocalStorage', () { - const testSessionValue = '{"key": "value"}'; - var testCount = 0; - - Future createFreshLocalStorage() async { - // 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 - 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; - } - - test('hasAccessToken returns false when no session exists', () async { - final localStorage = await createFreshLocalStorage(); - final result = await localStorage.hasAccessToken(); - expect(result, isFalse); - }); - - test('hasAccessToken returns true when session exists', () async { - final localStorage = await createFreshLocalStorage(); - await localStorage.persistSession(testSessionValue); - final result = await localStorage.hasAccessToken(); - expect(result, isTrue); - }); - - test('accessToken returns null when no session exists', () async { - final localStorage = await createFreshLocalStorage(); - final result = await localStorage.accessToken(); - expect(result, isNull); - }); - - test('accessToken returns session string when session exists', () async { - final localStorage = await createFreshLocalStorage(); - await localStorage.persistSession(testSessionValue); - final result = await localStorage.accessToken(); - expect(result, testSessionValue); - }); - - test('persistSession stores session string', () async { - final localStorage = await createFreshLocalStorage(); - await localStorage.persistSession(testSessionValue); - - // Verify the session was stored by checking through localStorage's own - // methods - final hasToken = await localStorage.hasAccessToken(); - expect(hasToken, isTrue); - - final storedValue = await localStorage.accessToken(); - expect(storedValue, testSessionValue); - }); - - test('removePersistedSession removes session', () async { - final localStorage = await createFreshLocalStorage(); - // First store a session - await localStorage.persistSession(testSessionValue); - expect(await localStorage.hasAccessToken(), isTrue); - - // Then remove it - await localStorage.removePersistedSession(); - expect(await localStorage.hasAccessToken(), isFalse); - expect(await localStorage.accessToken(), isNull); - }); + group('SharedPreferencesAuthAsyncStorage', () { + late SharedPreferencesAuthAsyncStorage storage; + const testKey = 'test_key'; + const testValue = 'test_value'; + + setUp(() { + mockSharedPreferences(); + storage = SharedPreferencesAuthAsyncStorage(); }); - // SharedPreferencesAuthAsyncStorage Tests - group('SharedPreferencesAuthAsyncStorage', () { - late SharedPreferencesAuthAsyncStorage asyncStorage; - const testKey = 'test_key'; - const testValue = 'test_value'; - - setUp(() { - // Set up fake shared preferences - mockSharedPreferences(); - asyncStorage = SharedPreferencesAuthAsyncStorage(); - }); + test( + 'setItem writes through SharedPreferencesAsync', + () async { + await storage.setItem(testKey, testValue); + expect(await SharedPreferencesAsync().getString(testKey), testValue); + }, + testOn: '!browser', + ); + + test('getItem returns null when there is no value', () async { + expect(await storage.getItem('non_existent_key'), isNull); + }); - test('setItem stores value for key', () async { - await asyncStorage.setItem(key: testKey, value: testValue); - final storedValue = await SharedPreferencesAsync().getString(testKey); - expect(storedValue, testValue); - }); + test('getItem returns the stored value', () async { + await storage.setItem(testKey, testValue); + expect(await storage.getItem(testKey), testValue); + }); - test('getItem returns null when no value exists', () async { - final result = await asyncStorage.getItem(key: 'non_existent_key'); - expect(result, isNull); - }); + test('setItem replaces an earlier value', () async { + await storage.setItem(testKey, testValue); + await storage.setItem(testKey, 'other'); + expect(await storage.getItem(testKey), 'other'); + }); - test('getItem returns value when value exists', () async { - await asyncStorage.setItem(key: testKey, value: testValue); - final result = await asyncStorage.getItem(key: testKey); - expect(result, testValue); - }); + test('removeItem removes the value', () async { + await storage.setItem(testKey, testValue); + expect(await storage.getItem(testKey), testValue); - test('removeItem removes value', () async { - // First store a value - await asyncStorage.setItem(key: testKey, value: testValue); - expect(await asyncStorage.getItem(key: testKey), testValue); + await storage.removeItem(testKey); + expect(await storage.getItem(testKey), isNull); + }); - // Then remove it - await asyncStorage.removeItem(key: testKey); - expect(await asyncStorage.getItem(key: testKey), isNull); - }); + test('removeItem does nothing when there is no value', () async { + await expectLater(storage.removeItem(testKey), completes); + expect(await storage.getItem(testKey), isNull); }); }); } diff --git a/packages/supabase_flutter/test/storage_web_test.dart b/packages/supabase_flutter/test/storage_web_test.dart new file mode 100644 index 000000000..c091aebd6 --- /dev/null +++ b/packages/supabase_flutter/test/storage_web_test.dart @@ -0,0 +1,82 @@ +@TestOn('browser') +library; + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:web/web.dart'; + +void main() { + group('SharedPreferencesAuthAsyncStorage on web', () { + late SharedPreferencesAuthAsyncStorage storage; + const testKey = 'sb-test-auth-token'; + + setUp(() { + window.localStorage.clear(); + SharedPreferences.setMockInitialValues({}); + storage = SharedPreferencesAuthAsyncStorage(); + }); + + test('writes the value as is to window.localStorage', () async { + await storage.setItem(testKey, '{"access_token":"token"}'); + + expect( + window.localStorage.getItem(testKey), + '{"access_token":"token"}', + ); + }); + + test( + 'reads a value another library wrote to window.localStorage', + () async { + window.localStorage.setItem(testKey, '{"access_token":"token"}'); + + expect(await storage.getItem(testKey), '{"access_token":"token"}'); + }, + ); + + test('removes the value from window.localStorage', () async { + window.localStorage.setItem(testKey, 'value'); + + await storage.removeItem(testKey); + + expect(window.localStorage.getItem(testKey), isNull); + }); + + test('decodes a verifier written by SharedPreferencesAsync', () async { + const verifierKey = '$testKey-code-verifier'; + window.localStorage.setItem(verifierKey, jsonEncode('raw-verifier')); + + expect(await storage.getItem(verifierKey), 'raw-verifier'); + expect(window.localStorage.getItem(verifierKey), 'raw-verifier'); + }); + + test('leaves a value that merely starts with a quote alone', () async { + window.localStorage.setItem(testKey, '"not json'); + + expect(await storage.getItem(testKey), '"not json'); + }); + + test('removeItem also removes the value of the legacy API', () async { + SharedPreferences.setMockInitialValues({testKey: 'legacy-session'}); + + await storage.removeItem(testKey); + + expect(await storage.getItem(testKey), isNull); + final legacyPreferences = await SharedPreferences.getInstance(); + expect(legacyPreferences.getString(testKey), isNull); + }); + + test('moves a verifier written by the legacy API over', () async { + const verifierKey = 'supabase.auth.token-code-verifier'; + SharedPreferences.setMockInitialValues({verifierKey: 'legacy-verifier'}); + + expect(await storage.getItem(verifierKey), 'legacy-verifier'); + expect(window.localStorage.getItem(verifierKey), 'legacy-verifier'); + final legacyPreferences = await SharedPreferences.getInstance(); + expect(legacyPreferences.getString(verifierKey), isNull); + }); + }); +} diff --git a/packages/supabase_flutter/test/supabase_flutter_test.dart b/packages/supabase_flutter/test/supabase_flutter_test.dart index db71e7b52..f91d7fbe9 100644 --- a/packages/supabase_flutter/test/supabase_flutter_test.dart +++ b/packages/supabase_flutter/test/supabase_flutter_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'utils.dart'; import 'widget_test_stubs.dart'; void main() { @@ -16,8 +17,9 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().add(const Duration(hours: 1)), + ), ), ); }); @@ -35,8 +37,9 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().add(const Duration(hours: 1)), + ), ), ); @@ -50,8 +53,9 @@ void main() { url: supabaseUrl, publishableKey: supabaseUrl, authOptions: FlutterAuthClientOptions( - localStorage: const MockLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().add(const Duration(hours: 1)), + ), ), accessToken: () async => 'my-access-token', ); @@ -66,21 +70,18 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockExpiredStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().subtract(const Duration(hours: 1)), + ), autoRefreshToken: false, ), ); }); - test('emits exception when no auto refresh', () async { - // The session recovery emits a `signedOut` event before the failure - // reaches the stream, and the subject replays only the latest event, - // so skip past any data events until the error arrives. - await expectLater( - Supabase.instance.client.auth.onAuthStateChange, - emitsThrough(emitsError(isA())), - ); + test('signs out when no auto refresh', () async { + await pumpEventQueue(); + + expect(Supabase.instance.client.auth.currentSession, isNull); }); }); @@ -91,8 +92,7 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockEmptyLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage(), ), ); }); @@ -104,53 +104,47 @@ void main() { }); }); - group('EmptyLocalStorage', () { - late EmptyLocalStorage localStorage; + group('Without session persistence', () { + late MockAsyncStorage storage; setUp(() async { mockAppLink(); - - localStorage = const EmptyLocalStorage(); - // Initialize the Supabase singleton + storage = MockAsyncStorage(); await Supabase.initialize( url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: localStorage, - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: storage, + persistSession: false, ), ); }); - test('all methods work together in a typical flow', () async { - // Initialize the storage - await localStorage.initialize(); - - // Check if there's a token (should be false) - final hasToken = await localStorage.hasAccessToken(); - expect(hasToken, isFalse); - - // Get the token (should be null) - final token = await localStorage.accessToken(); - expect(token, isNull); - - // Try to persist a session - await localStorage.persistSession('test-session-data'); - - // Check if there's a token after persisting (should still be false) - final hasTokenAfterPersist = await localStorage.hasAccessToken(); - expect(hasTokenAfterPersist, isFalse); + test('emits a null initial session', () async { + final event = await Supabase.instance.client.auth.onAuthStateChange.first; + expect(event.event, AuthChangeEvent.initialSession); + expect(event.session, isNull); + }); - // Get the token after persisting (should still be null) - final tokenAfterPersist = await localStorage.accessToken(); - expect(tokenAfterPersist, isNull); + test('does not restore a session from the storage', () async { + await Supabase.instance.dispose(); + await storage.setItem( + defaultPersistSessionKey(supabaseUrl), + getSessionData( + DateTime.now().add(const Duration(hours: 1)), + ).sessionString, + ); - // Try to remove the session - await localStorage.removePersistedSession(); + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + authOptions: FlutterAuthClientOptions( + asyncStorage: storage, + persistSession: false, + ), + ); - // Check if there's a token after removing (should still be false) - final hasTokenAfterRemove = await localStorage.hasAccessToken(); - expect(hasTokenAfterRemove, isFalse); + expect(Supabase.instance.client.auth.currentSession, isNull); }); }); } diff --git a/packages/supabase_flutter/test/utils.dart b/packages/supabase_flutter/test/utils.dart index 3edfc97b7..6243039dc 100644 --- a/packages/supabase_flutter/test/utils.dart +++ b/packages/supabase_flutter/test/utils.dart @@ -9,7 +9,7 @@ export 'package:supabase_test/supabase_test.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. +/// `SharedPreferencesAuthAsyncStorage` migrates a v2 value from. void mockSharedPreferences({Map legacyValues = const {}}) { SharedPreferences.setMockInitialValues(legacyValues); SharedPreferencesAsyncPlatform.instance = diff --git a/packages/supabase_flutter/test/widget_test.dart b/packages/supabase_flutter/test/widget_test.dart index dd9f124f6..4163aa723 100644 --- a/packages/supabase_flutter/test/widget_test.dart +++ b/packages/supabase_flutter/test/widget_test.dart @@ -20,8 +20,9 @@ void main() { url: supabaseUrl, publishableKey: supabaseKey, authOptions: FlutterAuthClientOptions( - localStorage: const MockLocalStorage(), - pkceAsyncStorage: MockAsyncStorage(), + asyncStorage: MockAsyncStorage.withSession( + DateTime.now().add(const Duration(hours: 1)), + ), ), ), ); diff --git a/packages/supabase_flutter/test/widget_test_stubs.dart b/packages/supabase_flutter/test/widget_test_stubs.dart index 214fff041..5076b78dc 100644 --- a/packages/supabase_flutter/test/widget_test_stubs.dart +++ b/packages/supabase_flutter/test/widget_test_stubs.dart @@ -53,59 +53,6 @@ class _MockWidgetState extends State { } } -/// Local storage that returns an expired session -class MockExpiredStorage extends LocalStorage { - const MockExpiredStorage(); - @override - Future initialize() async {} - @override - Future accessToken() async { - return getSessionData( - DateTime.now().subtract(const Duration(hours: 1)), - ).sessionString; - } - - @override - Future hasAccessToken() async => true; - @override - Future persistSession(String persistSessionString) async {} - @override - Future removePersistedSession() async {} -} - -class MockLocalStorage extends LocalStorage { - const MockLocalStorage(); - @override - Future initialize() async {} - @override - Future accessToken() async { - return getSessionData( - DateTime.now().add(const Duration(hours: 1)), - ).sessionString; - } - - @override - Future hasAccessToken() async => true; - @override - Future persistSession(String persistSessionString) async {} - @override - Future removePersistedSession() async {} -} - -class MockEmptyLocalStorage extends LocalStorage { - const MockEmptyLocalStorage(); - @override - Future initialize() async {} - @override - Future accessToken() async => null; - @override - Future hasAccessToken() async => false; - @override - Future persistSession(String persistSessionString) async {} - @override - Future removePersistedSession() async {} -} - /// Registers the mock handler for app_links /// /// Returns the [EventChannel] used to mock the incoming links. @@ -147,7 +94,21 @@ void mockAppLink({ MockSupabaseHttpClient createGetUserHttpClient(String email) => MockSupabaseHttpClient()..stub(testUserJson(email: email)); -class MockAsyncStorage extends MemoryAuthAsyncStorage {} +/// An in-memory storage for the tests, optionally seeded with a session. +class MockAsyncStorage extends MemoryAuthAsyncStorage { + MockAsyncStorage(); + + /// Holds a session expiring at [expiresAt] under the key the session is + /// stored under for [url]. + MockAsyncStorage.withSession(DateTime expiresAt, {String url = ''}) { + unawaited( + setItem( + defaultPersistSessionKey(url), + getSessionData(expiresAt).sessionString, + ), + ); + } +} /// Answers the token endpoint of the PKCE flow with a fresh session. MockSupabaseHttpClient createPkceHttpClient() => diff --git a/packages/supabase_test/lib/src/test_supabase_client.dart b/packages/supabase_test/lib/src/test_supabase_client.dart index e66c0bbd8..aeefa6600 100644 --- a/packages/supabase_test/lib/src/test_supabase_client.dart +++ b/packages/supabase_test/lib/src/test_supabase_client.dart @@ -48,7 +48,7 @@ SupabaseClient testSupabaseClient({ httpClient: httpClient, authOptions: AuthClientOptions( autoRefreshToken: autoRefreshToken, - pkceAsyncStorage: MemoryAuthAsyncStorage(), + asyncStorage: MemoryAuthAsyncStorage(), ), realtimeClientOptions: RealtimeClientOptions(transport: realtime?.call), ); diff --git a/packages/supabase_test/test/mock_supabase_http_client_test.dart b/packages/supabase_test/test/mock_supabase_http_client_test.dart index d91e52367..6b6a90c0d 100644 --- a/packages/supabase_test/test/mock_supabase_http_client_test.dart +++ b/packages/supabase_test/test/mock_supabase_http_client_test.dart @@ -456,7 +456,7 @@ void main() { 'apikey', httpClient: httpClient, authOptions: AuthClientOptions( - pkceAsyncStorage: MemoryAuthAsyncStorage(), + asyncStorage: MemoryAuthAsyncStorage(), ), postgrestOptions: const PostgrestClientOptions( retryOptions: SupabaseRetryOptions(enabled: false), @@ -507,7 +507,7 @@ void main() { 'apikey', httpClient: httpClient, authOptions: AuthClientOptions( - pkceAsyncStorage: MemoryAuthAsyncStorage(), + asyncStorage: MemoryAuthAsyncStorage(), ), postgrestOptions: const PostgrestClientOptions( requestTimeout: Duration(milliseconds: 50), diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index e7508e6a1..ef99f404a 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -2266,27 +2266,14 @@ features: status: implemented symbols: - AuthAsyncStorage - - LocalStorage - supporting_symbols: - - AuthClientOptions.pkceAsyncStorage - - EmptyLocalStorage - - EmptyLocalStorage.EmptyLocalStorage - - EmptyLocalStorage.accessToken - - EmptyLocalStorage.hasAccessToken - - EmptyLocalStorage.initialize - - EmptyLocalStorage.persistSession - - EmptyLocalStorage.removePersistedSession - - FlutterAuthClientOptions.localStorage + - AuthClientOptions.asyncStorage + supporting_symbols: - AuthAsyncStorage.AuthAsyncStorage - AuthAsyncStorage.getItem - AuthAsyncStorage.removeItem - AuthAsyncStorage.setItem - - LocalStorage.LocalStorage - - LocalStorage.accessToken - - LocalStorage.hasAccessToken - - LocalStorage.initialize - - LocalStorage.persistSession - - LocalStorage.removePersistedSession + - AuthClientOptions.storageKey + - AuthClient.storageKey - MemoryAuthAsyncStorage - MemoryAuthAsyncStorage.getItem - MemoryAuthAsyncStorage.removeItem @@ -2296,21 +2283,14 @@ features: - SharedPreferencesAuthAsyncStorage.getItem - SharedPreferencesAuthAsyncStorage.removeItem - SharedPreferencesAuthAsyncStorage.setItem - - SharedPreferencesLocalStorage - - SharedPreferencesLocalStorage.SharedPreferencesLocalStorage - - SharedPreferencesLocalStorage.accessToken - - SharedPreferencesLocalStorage.hasAccessToken - - SharedPreferencesLocalStorage.initialize - - SharedPreferencesLocalStorage.persistSession - - SharedPreferencesLocalStorage.removePersistedSession client.session_management.persist_session: status: implemented symbols: - AuthClientOptions.persistSession + - AuthClient.initialized - AuthClient.recoverSession - AuthClient.setInitialSession supporting_symbols: - - SharedPreferencesLocalStorage.persistSessionKey - defaultPersistSessionKey client.request_configuration.custom_http_client: status: implemented