diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 944aff26d..4a788e3f0 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -30,9 +30,17 @@ part 'gotrue_mfa_api.dart'; /// /// [autoRefreshToken] whether to refresh the token automatically or not. Defaults to true. /// +/// [persistSession] whether to persist the session via [asyncStorage] or not. +/// Defaults to false. Session is only broadcasted via [BroadcastChannel] if +/// set to true. +/// /// [httpClient] custom http client. /// -/// [asyncStorage] local storage to store pkce code verifiers. Required when using the pkce flow. +/// [asyncStorage] local storage to store sessions and pkce code verifiers. +/// Required when using the pkce flow and persisting sessions. +/// +/// [storageKey] key to store the session with in [asyncStorage]. +/// The pkce code verifiers are suffixed with `-code-verifier`. /// /// Set [flowType] to [AuthFlowType.implicit] to perform old implicit auth flow. /// {@endtemplate} @@ -73,9 +81,24 @@ class GoTrueClient { sync: true, ); - /// Local storage to store pkce code verifiers. + /// Local storage to store session and pkce code verifiers. + /// + /// Use [_readyAsyncStorage] to get the instance of the storage, as it ensures + /// that the storage is initialized before use. final GotrueAsyncStorage? _asyncStorage; + Future get _readyAsyncStorage async { + if (_asyncStorage == null) { + throw AuthException( + 'No async storage provided. You must provide an async storage to persist sessions or use PKCE flow.', + ); + } + if (!_initializedStorage.isCompleted) { + await _initializedStorage.future; + } + return _asyncStorage; + } + /// Receive a notification every time an auth event happens. /// /// Network errors (e.g. when the device is offline) are emitted as stream @@ -107,8 +130,20 @@ class GoTrueClient { Stream get onAuthStateChangeSync => _onAuthStateChangeControllerSync.stream; + /// Completes when the [_asyncStorage] is initialized. + /// + /// Initialization is started in the constructor and should be awaited before + /// accessing the storage. + final Completer _initializedStorage = Completer(); + final AuthFlowType _flowType; + final bool _persistSession; + + /// Key to store the session with in [_asyncStorage]. + /// The pkce code verifiers are suffixed with `-code-verifier`. + final String _storageKey; + final _log = Logger('supabase.auth'); /// Proxy to the web BroadcastChannel API. Should be null on non-web platforms. @@ -121,20 +156,23 @@ class GoTrueClient { String? url, Map? headers, bool? autoRefreshToken, + bool? persistSession, Client? httpClient, GotrueAsyncStorage? asyncStorage, + String? storageKey, AuthFlowType flowType = AuthFlowType.pkce, }) : _url = url ?? Constants.defaultGotrueUrl, _headers = {...Constants.defaultHeaders, ...?headers}, _httpClient = httpClient, _asyncStorage = asyncStorage, - _flowType = flowType { + _flowType = flowType, + _persistSession = persistSession ?? false, + _storageKey = storageKey ?? Constants.defaultStorageKey { _autoRefreshToken = autoRefreshToken ?? true; final gotrueUrl = url ?? Constants.defaultGotrueUrl; _log.config( - 'Initialize GoTrueClient v$version with url: $_url, autoRefreshToken: $_autoRefreshToken, flowType: $_flowType, tickDuration: ${Constants.autoRefreshTickDuration}, tickThreshold: ${Constants.autoRefreshTickThreshold}', - ); + 'Initialize GoTrueClient v$version with url: $_url, persistSession: $_persistSession, _storageKey: $storageKey, autoRefreshToken: $_autoRefreshToken, flowType: $_flowType, tickDuration: ${Constants.autoRefreshTickDuration}, tickThreshold: ${Constants.autoRefreshTickThreshold}'); _log.finest('Initialize with headers: $_headers'); admin = GoTrueAdminApi( gotrueUrl, @@ -142,22 +180,60 @@ class GoTrueClient { httpClient: httpClient, ); mfa = GoTrueMFAApi(client: this, fetch: _fetch); + + assert(asyncStorage != null || !_persistSession, + 'You need to provide asyncStorage to persist session.'); + if (asyncStorage != null) { + _initializedStorage.complete( + asyncStorage.initialize().catchError((e) => notifyException(e))); + } + if (_autoRefreshToken) { startAutoRefresh(); } + _initialize(); _mayStartBroadcastChannel(); } /// Getter for the headers Map get headers => _headers; - /// Returns the current logged in user, asociated to [currentSession] if any; + /// Returns the current logged in user, associated to [currentSession] if any; User? get currentUser => _currentSession?.user; /// Returns the current session, if any; Session? get currentSession => _currentSession; + /// This method should not throw as it is called from the constructor. + Future _initialize() async { + try { + if (_persistSession && _asyncStorage != null) { + await _initializedStorage.future; + final jsonStr = await (await _readyAsyncStorage).getItem(_storageKey); + if (jsonStr != null) { + // Emits an initial session event or exception + await setInitialSession(jsonStr); + + // Only try to recover session if the session got set in [setInitialSession] + // because if not the session is missing data and already notified an + // exception. + if (currentSession != null) { + // [notifyException] gets already called here if needed, so we can + // catch any error. + recoverSession(jsonStr).then((_) {}, onError: (_) {}); + } + } else { + // Emit a null session if the user did not have persisted session + notifyAllSubscribers(AuthChangeEvent.initialSession); + } + } + } catch (error, stackTrace) { + _log.warning('Error while loading initial session', error, stackTrace); + notifyException(error, stackTrace); + } + } + /// Creates a new anonymous user. /// /// Returns An `AuthResponse` with a session where the `is_anonymous` claim @@ -182,7 +258,7 @@ class GoTrueClient { final session = authResponse.session; if (session != null) { - _saveSession(session); + await _saveSession(session); notifyAllSubscribers(AuthChangeEvent.signedIn); } @@ -231,10 +307,8 @@ class GoTrueClient { 'You need to provide asyncStorage to perform pkce flow.', ); final codeVerifier = generatePKCEVerifier(); - await _asyncStorage!.setItem( - key: '${Constants.defaultStorageKey}-code-verifier', - value: codeVerifier, - ); + await (await _readyAsyncStorage) + .setItem('$_storageKey-code-verifier', codeVerifier); codeChallenge = generatePKCEChallenge(codeVerifier); } @@ -278,7 +352,7 @@ class GoTrueClient { final session = authResponse.session; if (session != null) { - _saveSession(session); + await _saveSession(session); notifyAllSubscribers(AuthChangeEvent.signedIn); } @@ -331,7 +405,7 @@ class GoTrueClient { final authResponse = AuthResponse.fromJson(response); if (authResponse.session?.accessToken != null) { - _saveSession(authResponse.session!); + await _saveSession(authResponse.session!); notifyAllSubscribers(AuthChangeEvent.signedIn); } return authResponse; @@ -360,9 +434,8 @@ class GoTrueClient { 'You need to provide asyncStorage to perform pkce flow.', ); - final codeVerifierRawString = await _asyncStorage!.getItem( - key: '${Constants.defaultStorageKey}-code-verifier', - ); + final codeVerifierRawString = + await (await _readyAsyncStorage).getItem('$_storageKey-code-verifier'); if (codeVerifierRawString == null) { throw AuthException('Code verifier could not be found in local storage.'); } @@ -380,9 +453,7 @@ class GoTrueClient { ), ); - await _asyncStorage.removeItem( - key: '${Constants.defaultStorageKey}-code-verifier', - ); + await (await _readyAsyncStorage).removeItem('$_storageKey-code-verifier'); final authSessionUrlResponse = AuthSessionUrlResponse( session: Session.fromJson(response)!, @@ -390,7 +461,7 @@ class GoTrueClient { ); final session = authSessionUrlResponse.session; - _saveSession(session); + await _saveSession(session); if (redirectType == AuthChangeEvent.passwordRecovery) { notifyAllSubscribers(AuthChangeEvent.passwordRecovery); } else { @@ -441,7 +512,7 @@ class GoTrueClient { throw AuthException('An error occurred on token verification.'); } - _saveSession(authResponse.session!); + await _saveSession(authResponse.session!); notifyAllSubscribers(AuthChangeEvent.signedIn); return authResponse; @@ -481,10 +552,8 @@ class GoTrueClient { 'You need to provide asyncStorage to perform pkce flow.', ); final codeVerifier = generatePKCEVerifier(); - await _asyncStorage!.setItem( - key: '${Constants.defaultStorageKey}-code-verifier', - value: codeVerifier, - ); + await (await _readyAsyncStorage) + .setItem('$_storageKey-code-verifier', codeVerifier); codeChallenge = generatePKCEChallenge(codeVerifier); } await _fetch.request( @@ -589,12 +658,10 @@ class GoTrueClient { throw AuthException('An error occurred on token verification.'); } - _saveSession(authResponse.session!); - notifyAllSubscribers( - type == OtpType.recovery - ? AuthChangeEvent.passwordRecovery - : AuthChangeEvent.signedIn, - ); + await _saveSession(authResponse.session!); + notifyAllSubscribers(type == OtpType.recovery + ? AuthChangeEvent.passwordRecovery + : AuthChangeEvent.signedIn); return authResponse; } @@ -628,10 +695,8 @@ class GoTrueClient { 'You need to provide asyncStorage to perform pkce flow.', ); final codeVerifier = generatePKCEVerifier(); - await _asyncStorage!.setItem( - key: '${Constants.defaultStorageKey}-code-verifier', - value: codeVerifier, - ); + await (await _readyAsyncStorage) + .setItem('$_storageKey-code-verifier', codeVerifier); codeChallenge = generatePKCEChallenge(codeVerifier); codeChallengeMethod = codeVerifier == codeChallenge ? 'plain' : 's256'; } @@ -920,7 +985,7 @@ class GoTrueClient { final redirectType = url.queryParameters['type']; if (storeSession == true) { - _saveSession(session); + await _saveSession(session); if (redirectType == 'recovery') { notifyAllSubscribers(AuthChangeEvent.passwordRecovery); } else { @@ -941,10 +1006,11 @@ class GoTrueClient { final accessToken = currentSession?.accessToken; if (scope != SignOutScope.others) { - _removeSession(); - await _asyncStorage?.removeItem( - key: '${Constants.defaultStorageKey}-code-verifier', - ); + await _removeSession(); + if (_asyncStorage != null) { + await (await _readyAsyncStorage) + .removeItem('$_storageKey-code-verifier'); + } notifyAllSubscribers(AuthChangeEvent.signedOut); } @@ -977,9 +1043,9 @@ class GoTrueClient { 'You need to provide asyncStorage to perform pkce flow.', ); final codeVerifier = generatePKCEVerifier(); - await _asyncStorage!.setItem( - key: '${Constants.defaultStorageKey}-code-verifier', - value: '$codeVerifier/${AuthChangeEvent.passwordRecovery.name}', + await (await _readyAsyncStorage).setItem( + '$_storageKey-code-verifier', + '$codeVerifier/${AuthChangeEvent.passwordRecovery.name}', ); codeChallenge = generatePKCEChallenge(codeVerifier); } @@ -1104,7 +1170,7 @@ class GoTrueClient { if (session == null) { // sign out to delete the local storage from supabase_flutter await signOut(); - throw notifyException(AuthException('Initial session is missing data.')); + throw AuthException('Initial session is missing data.'); } _currentSession = session; @@ -1118,9 +1184,7 @@ class GoTrueClient { if (session == null) { _log.warning("Can't recover session from string, session is null"); await signOut(); - throw notifyException( - AuthException('Current session is missing data.'), - ); + throw AuthException('Session to restore is missing data.'); } if (session.isExpired) { @@ -1134,8 +1198,8 @@ class GoTrueClient { } } else { final shouldEmitEvent = _currentSession == null || - _currentSession!.user.id != session.user.id; - _saveSession(session); + _currentSession?.user.id != session.user.id; + await _saveSession(session); if (shouldEmitEvent) { notifyAllSubscribers(AuthChangeEvent.tokenRefreshed); @@ -1271,9 +1335,9 @@ class GoTrueClient { 'You need to provide asyncStorage to perform pkce flow.', ); final codeVerifier = generatePKCEVerifier(); - await _asyncStorage!.setItem( - key: '${Constants.defaultStorageKey}-code-verifier', - value: codeVerifier, + await (await _readyAsyncStorage).setItem( + '$_storageKey-code-verifier', + codeVerifier, ); final codeChallenge = generatePKCEChallenge(codeVerifier); @@ -1292,19 +1356,30 @@ class GoTrueClient { } /// set currentSession and currentUser - void _saveSession(Session session) { + Future _saveSession(Session session) async { _log.finest('Saving session: $session'); _log.fine('Saving session'); _currentSession = session; + if (_persistSession && _asyncStorage != null) { + await (await _readyAsyncStorage).setItem( + _storageKey, + jsonEncode(session.toJson()), + ); + } } - void _removeSession() { + Future _removeSession() async { _log.fine('Removing session'); _currentSession = null; + + if (_persistSession && _asyncStorage != null) { + await (await _readyAsyncStorage).removeItem(_storageKey); + } } void _mayStartBroadcastChannel() { - if (const bool.fromEnvironment('dart.library.js_interop')) { + if (_persistSession && + const bool.fromEnvironment('dart.library.js_interop')) { // Used by the js library as well final broadcastKey = "sb-${Uri.parse(_url).host.split(".").first}-auth-token"; @@ -1315,9 +1390,8 @@ class GoTrueClient { ); try { _broadcastChannel = web.getBroadcastChannel(broadcastKey); - _broadcastChannelSubscription = _broadcastChannel?.onMessage.listen(( - messageEvent, - ) { + _broadcastChannelSubscription = + _broadcastChannel?.onMessage.listen((messageEvent) async { final rawEvent = messageEvent['event']; _log.finest('Received broadcast message: $messageEvent'); _log.info('Received broadcast event: $rawEvent'); @@ -1343,9 +1417,9 @@ class GoTrueClient { session = Session.fromJson(messageEvent['session']); } if (session != null) { - _saveSession(session); + await _saveSession(session); } else { - _removeSession(); + await _removeSession(); } notifyAllSubscribers(event, session: session, broadcast: false); } @@ -1399,14 +1473,14 @@ class GoTrueClient { throw AuthSessionMissingException(); } - _saveSession(session); + await _saveSession(session); notifyAllSubscribers(AuthChangeEvent.tokenRefreshed); _refreshTokenCompleter?.complete(data); return data; } on AuthException catch (error, stack) { if (error is! AuthRetryableFetchException) { - _removeSession(); + await _removeSession(); notifyAllSubscribers(AuthChangeEvent.signedOut); } else { notifyException(error, stack); diff --git a/packages/gotrue/lib/src/types/gotrue_async_storage.dart b/packages/gotrue/lib/src/types/gotrue_async_storage.dart index 29ce2b15d..f9e521e1d 100644 --- a/packages/gotrue/lib/src/types/gotrue_async_storage.dart +++ b/packages/gotrue/lib/src/types/gotrue_async_storage.dart @@ -2,15 +2,15 @@ abstract class GotrueAsyncStorage { const GotrueAsyncStorage(); + /// May be implemented to allow for initialization of the storage before use. + Future initialize() async {} + /// Retrieves an item asynchronously from the storage with the key. - Future getItem({required String key}); + Future getItem(String key); /// Stores the value asynchronously to the storage with the key. - Future setItem({ - required String key, - required String value, - }); + Future setItem(String key, String value); /// Removes an item asynchronously from the storage for the given key. - Future removeItem({required String key}); + Future removeItem(String key); } diff --git a/packages/gotrue/lib/src/types/types.dart b/packages/gotrue/lib/src/types/types.dart index c9660e37c..845c321b2 100644 --- a/packages/gotrue/lib/src/types/types.dart +++ b/packages/gotrue/lib/src/types/types.dart @@ -1,3 +1,5 @@ +/// An interface to use [html.BroadcastChannel] on web to broadcast sessions to +/// other tabs. typedef BroadcastChannel = ({ Stream> onMessage, void Function(Map) postMessage, diff --git a/packages/gotrue/test/initialize_test.dart b/packages/gotrue/test/initialize_test.dart new file mode 100644 index 000000000..930c8b816 --- /dev/null +++ b/packages/gotrue/test/initialize_test.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:gotrue/gotrue.dart'; +import 'package:gotrue/src/constants.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('GoTrueClient._initialize()', () { + const defaultGotrueUrl = 'http://localhost:9998'; + const defaultHeaders = { + 'Authorization': 'Bearer test_token', + 'apikey': 'test_token', + }; + + group('with persistSession = false', () { + test('does not attempt to load session from storage', () async { + final storage = TestAsyncStorage(); + final sessionData = getSessionData( + DateTime.now().add(const Duration(hours: 1)), + ); + // Store with default key + await storage.setItem( + Constants.defaultStorageKey, + sessionData.sessionString, + ); + + final client = GoTrueClient( + url: defaultGotrueUrl, + headers: defaultHeaders, + asyncStorage: storage, + persistSession: false, + ); + + // Wait for initialization to complete + await Future.delayed(const Duration(milliseconds: 300)); + expect(client.currentSession, isNull); + }); + + test('does not require asyncStorage', () async { + // Should not throw even without asyncStorage + final client = GoTrueClient( + url: defaultGotrueUrl, + headers: defaultHeaders, + persistSession: false, + ); + + expect(client.currentSession, isNull); + }); + }); + + group('with persistSession = true', () { + test('load session and emit initial session event', () async { + final authStateChanges = []; + final storage = TestAsyncStorage(); + final sessionData = getSessionData( + DateTime.now().add(const Duration(hours: 1)), + ); + await storage.setItem( + Constants.defaultStorageKey, + sessionData.sessionString, + ); + + final client = GoTrueClient( + url: defaultGotrueUrl, + headers: defaultHeaders, + asyncStorage: storage, + persistSession: true, + ); + + expect(client.currentSession, isNull); + + final subscription = client.onAuthStateChange.listen( + (state) => authStateChanges.add(state), + ); + + // Wait for initialization + await Future.delayed(const Duration(milliseconds: 300)); + + expect(client.currentSession, isNotNull); + + subscription.cancel(); + + expect( + authStateChanges.length, + greaterThanOrEqualTo(1), + ); + expect( + authStateChanges.first.event, + AuthChangeEvent.initialSession, + ); + }); + + test('emits initialSession event when no session is stored', () async { + final authStateChanges = []; + final storage = TestAsyncStorage(); + + final client = GoTrueClient( + url: defaultGotrueUrl, + headers: defaultHeaders, + asyncStorage: storage, + persistSession: true, + ); + + final subscription = client.onAuthStateChange.listen( + (state) => authStateChanges.add(state), + ); + + // Wait for initialization + await Future.delayed(const Duration(milliseconds: 300)); + + subscription.cancel(); + + expect(authStateChanges.length, greaterThanOrEqualTo(1)); + expect( + authStateChanges.first.event, + AuthChangeEvent.initialSession, + ); + expect(authStateChanges.first.session, isNull); + }); + + test('handles corrupted session data gracefully', () async { + final authExceptions = []; + final authEvents = []; + final storage = TestAsyncStorage(); + await storage.setItem( + Constants.defaultStorageKey, '{"invalid": "json"}'); + + final client = GoTrueClient( + url: defaultGotrueUrl, + headers: defaultHeaders, + asyncStorage: storage, + persistSession: true, + ); + + final subscription = client.onAuthStateChange.listen( + (state) => authEvents.add(state), + onError: (error) => authExceptions.add(error as Object), + ); + + // Wait for initialization + await Future.delayed(const Duration(milliseconds: 300)); + + subscription.cancel(); + + expect(client.currentSession, isNull); + expect(authExceptions.length, equals(1)); + expect( + authExceptions.first, + isA(), + ); + + expect(authEvents.length, equals(1)); + expect( + authEvents.first.event, + AuthChangeEvent.signedOut, + ); + }); + + test('calls recoverSession after setting initial session', () async { + final storage = TestAsyncStorage(); + final sessionData = getSessionData( + DateTime.now().subtract(const Duration(hours: 2)), + ); + await storage.setItem( + Constants.defaultStorageKey, + sessionData.sessionString, + ); + + final client = GoTrueClient( + url: defaultGotrueUrl, + headers: defaultHeaders, + asyncStorage: storage, + persistSession: true, + ); + final authEvents = []; + final authExceptions = []; + + client.onAuthStateChange.listen( + (state) => authEvents.add(state), + onError: (error) => authExceptions.add(error as Object), + ); + + // Wait for initialization + await Future.delayed(const Duration(milliseconds: 300)); + + expect( + authEvents.length, + equals(2), + ); + + // We first get the initial and expired session loaded from storage. + expect( + authEvents.first.event, + AuthChangeEvent.initialSession, + ); + expect(authEvents.first.session, isNotNull); + + expect(client.currentSession, isNull); + expect(authExceptions.length, equals(1)); + expect( + authExceptions.first, + isA(), + ); + + // The client tried to recover the session, but the stub session has + // no valid refresh token, so we expect a failure with that message + expect(authExceptions.first.toString(), + contains('Refresh token is not valid')); + + // The invalid refresh token causes a sign out. + expect( + authEvents[1].event, + AuthChangeEvent.signedOut, + ); + expect(authEvents[1].session, isNull); + expect(client.currentSession, isNull); + }); + }); + }); +} diff --git a/packages/gotrue/test/utils.dart b/packages/gotrue/test/utils.dart index 10b4f5b99..5cfbe541b 100644 --- a/packages/gotrue/test/utils.dart +++ b/packages/gotrue/test/utils.dart @@ -65,17 +65,17 @@ String getServiceRoleToken(DotEnv env) { class TestAsyncStorage extends GotrueAsyncStorage { final Map _map = {}; @override - Future getItem({required String key}) async { + Future getItem(String key) async { return _map[key]; } @override - Future removeItem({required String key}) async { + Future removeItem(String key) async { _map.remove(key); } @override - Future setItem({required String key, required String value}) async { + Future setItem(String key, String value) async { _map[key] = value; } } diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 5796c940a..dfac2a9ef 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -11,22 +11,21 @@ import 'auth_http_client.dart'; import 'counter.dart'; /// {@template supabase_client} +/// /// Creates a Supabase client to interact with your Supabase instance. /// /// [supabaseUrl] and [supabaseKey] can be found on your Supabase dashboard. /// Pass the `publishable` (anon) key for client-side usage or the `secret` /// key for trusted server-side environments. /// -/// You can access none public schema by passing different [schema]. -/// /// Default headers can be overridden by specifying [headers]. /// /// Custom http client can be used by passing [httpClient] parameter. /// -/// [storageRetryAttempts] specifies how many retry attempts there should be to -/// upload a file to Supabase storage when failed due to network interruption. -/// -/// [realtimeClientOptions] specifies different options you can pass to `RealtimeClient`. +/// [realtimeClientOptions], [authOptions], [storageOptions], +/// [postgrestOptions] specify different options you can pass to +/// [RealtimeClient], [GoTrueClient], [SupabaseStorageClient], +/// [PostgrestClient]. /// /// [accessToken] Optional function for using a third-party authentication system with Supabase. /// The function should return an access token or ID token (JWT) by obtaining @@ -38,8 +37,6 @@ import 'counter.dart'; /// Pass an instance of `YAJsonIsolate` to [isolate] to use your own persisted /// isolate instance. A new instance will be created if [isolate] is omitted. /// -/// Pass an instance of [gotrueAsyncStorage] and set the [authFlowType] to -/// `AuthFlowType.pkce`in order to perform auth actions with pkce flow. /// {@endtemplate} class SupabaseClient { final String _supabaseKey; @@ -144,11 +141,7 @@ class SupabaseClient { _httpClient = httpClient, _isolate = isolate ?? (YAJsonIsolate()..initialize()), _hasCustomIsolate = isolate != null { - _authInstance = _initSupabaseAuthClient( - autoRefreshToken: authOptions.autoRefreshToken, - gotrueAsyncStorage: authOptions.pkceAsyncStorage, - authFlowType: authOptions.authFlowType, - ); + _authInstance = _initSupabaseAuthClient(authOptions: authOptions); _authHttpClient = AuthHttpClient(_supabaseKey, httpClient ?? Client(), _getAccessToken); rest = _initRestClient(); @@ -284,11 +277,8 @@ class SupabaseClient { _authInstance?.dispose(); } - GoTrueClient _initSupabaseAuthClient({ - bool? autoRefreshToken, - required GotrueAsyncStorage? gotrueAsyncStorage, - required AuthFlowType authFlowType, - }) { + GoTrueClient _initSupabaseAuthClient( + {required AuthClientOptions authOptions}) { final authHeaders = {...headers}; authHeaders['apikey'] = _supabaseKey; authHeaders['Authorization'] = 'Bearer $_supabaseKey'; @@ -296,10 +286,12 @@ class SupabaseClient { return GoTrueClient( url: _authUrl, headers: authHeaders, - autoRefreshToken: autoRefreshToken, + autoRefreshToken: authOptions.autoRefreshToken, httpClient: _httpClient, - asyncStorage: gotrueAsyncStorage, - flowType: authFlowType, + asyncStorage: authOptions.asyncStorage, + storageKey: authOptions.storageKey, + persistSession: authOptions.persistSession, + flowType: authOptions.authFlowType, ); } diff --git a/packages/supabase/lib/src/supabase_client_options.dart b/packages/supabase/lib/src/supabase_client_options.dart index 12045bed6..9097f49fe 100644 --- a/packages/supabase/lib/src/supabase_client_options.dart +++ b/packages/supabase/lib/src/supabase_client_options.dart @@ -6,30 +6,70 @@ class PostgrestClientOptions { const PostgrestClientOptions({this.schema = 'public'}); } +/// {@template supabase_auth_client_options} +/// +/// Configuration for the auth client with appropriate default values when using +/// the `supabase` package. For usage via `supabase_flutter` use +/// [FlutterAuthClientOptions] instead +/// +/// [autoRefreshToken] whether to refresh the token automatically or not. Defaults to true. +/// +/// [asyncStorage] a storage interface to store sessions +/// (if [persistSession] is `true`) and pkce code verifiers +/// (if [authFlowType] is [AuthFlowType.pkce]), which is the default. +/// +/// [storageKey] key to store the session with in [asyncStorage]. +/// The pkce code verifiers are suffixed with `-code-verifier` +/// +/// [persistSession] whether to persist the session via [asyncStorage] or not. +/// Session is only broadcasted via [BroadcastChannel] if set to true. +/// +/// Set [authFlowType] to [AuthFlowType.implicit] to use the old implicit flow for authentication +/// involving deep links. +/// +/// {@endtemplate} class AuthClientOptions { final bool autoRefreshToken; + + @Deprecated( + "The storage for the session is now handled by the auth client itself and is combined with the storage for pkce, so please use [asyncStorage] instead") final GotrueAsyncStorage? pkceAsyncStorage; + final GotrueAsyncStorage? asyncStorage; + final AuthFlowType authFlowType; + final String? storageKey; + final bool persistSession; + + /// {@macro supabase_auth_client_options} const AuthClientOptions({ this.autoRefreshToken = true, + @Deprecated( + "The storage for session and pkce is now combined, so use [asyncStorage] instead") this.pkceAsyncStorage, + this.asyncStorage, this.authFlowType = AuthFlowType.pkce, + this.storageKey, + this.persistSession = false, }); } class StorageClientOptions { final int retryAttempts; - /// Whether to rewrite legacy storage URLs to use the dedicated storage host - /// (`.storage.supabase.co`). Enables uploads larger than 50 GB by - /// bypassing proxy buffering limits. + final bool useNewHostname; + + /// [retryAttempts] specifies how many retry attempts there should be + /// to upload a file to Supabase storage when failed due to network + /// interruption. + + /// Use [useNewHostname] to configure rewriting legacy storage URLs to use the + /// dedicated storage host (`.storage.supabase.co`). + /// Enables uploads larger than 50 GB by bypassing proxy buffering limits. /// /// Set to `true` only if your project has the dedicated storage host /// enabled; otherwise every storage request will fail with an /// `Invalid Storage request` error. Defaults to `false` (opt-in). - final bool useNewHostname; - const StorageClientOptions( {this.retryAttempts = 0, this.useNewHostname = false}); } diff --git a/packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart b/packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart index 2c82a0f72..8652acb62 100644 --- a/packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart +++ b/packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart @@ -1,16 +1,48 @@ import 'package:supabase_flutter/supabase_flutter.dart'; +/// {@template supabase_flutter_auth_client_options} +/// +/// [autoRefreshToken] whether to refresh the token automatically or not. Defaults to true. +/// +/// [asyncStorage] a storage interface to store sessions +/// (if [persistSession] is `true`) and pkce code verifiers +/// (if [authFlowType] is [AuthFlowType.pkce]) +/// +/// [storageKey] key to store the session with in [asyncStorage]. +/// The pkce code verifiers are suffixed with `-code-verifier` +/// +/// [persistSession] whether to persist the session via [asyncStorage] or not. +/// Session is only broadcasted via [BroadcastChannel] if set to true. +/// +/// Set [authFlowType] to [AuthFlowType.implicit] to use the old implicit flow for authentication +/// involving deep links. +/// +/// [detectSessionInUri] If true, the client will start the deep link observer and obtain sessions +/// when a valid URI is detected. +/// +/// PKCE flow uses shared preferences for storing the code verifier by default. +/// Pass a custom storage to [asyncStorage] to override the behavior. +/// +/// {@endtemplate} class FlutterAuthClientOptions extends AuthClientOptions { + @Deprecated( + "The storage for the session is now handled by the auth client itself and is combined with the storage for pkce, so please use [asyncStorage] insetad") 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; + /// {@macro supabase_flutter_auth_client_options} const FlutterAuthClientOptions({ super.authFlowType, super.autoRefreshToken, + @Deprecated( + "The storage for session and pkce is now combined, so use [asyncStorage] instead") super.pkceAsyncStorage, + super.asyncStorage, + super.storageKey, + super.persistSession = true, + @Deprecated( + "The storage for session and pkce is now combined, so use [asyncStorage] instead") this.localStorage, this.detectSessionInUri = true, }); @@ -20,13 +52,21 @@ class FlutterAuthClientOptions extends AuthClientOptions { bool? autoRefreshToken, LocalStorage? localStorage, GotrueAsyncStorage? pkceAsyncStorage, + GotrueAsyncStorage? asyncStorage, + String? storageKey, + bool? persistSession, bool? detectSessionInUri, }) { return FlutterAuthClientOptions( authFlowType: authFlowType ?? this.authFlowType, autoRefreshToken: autoRefreshToken ?? this.autoRefreshToken, + // ignore: deprecated_member_use_from_same_package localStorage: localStorage ?? this.localStorage, + // ignore: deprecated_member_use pkceAsyncStorage: pkceAsyncStorage ?? this.pkceAsyncStorage, + asyncStorage: asyncStorage ?? this.asyncStorage, + storageKey: storageKey ?? this.storageKey, + persistSession: persistSession ?? this.persistSession, detectSessionInUri: detectSessionInUri ?? this.detectSessionInUri, ); } diff --git a/packages/supabase_flutter/lib/src/local_storage.dart b/packages/supabase_flutter/lib/src/local_storage.dart index d3622ea28..badbb12a2 100644 --- a/packages/supabase_flutter/lib/src/local_storage.dart +++ b/packages/supabase_flutter/lib/src/local_storage.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/src/supabase_auth.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import './local_storage_stub.dart' @@ -17,9 +18,7 @@ const supabasePersistSessionKey = 'SUPABASE_PERSIST_SESSION_KEY'; /// /// * [SupabaseAuth], the instance used to manage authentication /// * [EmptyLocalStorage], used to disable session persistence -/// * [HiveLocalStorage], that implements Hive as storage method /// * [SharedPreferencesLocalStorage], that implements SharedPreferences as storage method -/// * [MigrationLocalStorage], to migrate from Hive to SharedPreferences abstract class LocalStorage { const LocalStorage(); @@ -69,6 +68,12 @@ class SharedPreferencesLocalStorage extends LocalStorage { SharedPreferencesLocalStorage({required this.persistSessionKey}); final String persistSessionKey; + + /// Shared preferences already use the local storage on web, but the package + /// adds an additional prefix to the key. + /// To support integrating with a session stored by the supabase-js client, + /// we need to access the local storage directly on web, and use the shared + /// preferences on other platforms. static const _useWebLocalStorage = kIsWeb && bool.fromEnvironment("dart.library.js_interop"); @@ -114,37 +119,101 @@ class SharedPreferencesLocalStorage extends LocalStorage { } } -/// local storage to store pkce flow code verifier. +/// A [GotrueAsyncStorage] implementation that implements SharedPreferences as +/// the storage method. class SharedPreferencesGotrueAsyncStorage extends GotrueAsyncStorage { - SharedPreferencesGotrueAsyncStorage() { - _initialize(); - } - - final Completer _initializationCompleter = Completer(); - late final SharedPreferences _prefs; - Future _initialize() async { - WidgetsFlutterBinding.ensureInitialized(); - _prefs = await SharedPreferences.getInstance(); - _initializationCompleter.complete(); + /// Shared preferences already use the local storage on web, but the package + /// adds an additional prefix to the key. + /// To support integrating with a session/pkce token stored by the supabase-js + /// client, we need to access the local storage directly on web, and use the + /// shared preferences on other platforms. + static const _useWebLocalStorage = + kIsWeb && bool.fromEnvironment("dart.library.js_interop"); + + @override + Future initialize() async { + if (!_useWebLocalStorage) { + WidgetsFlutterBinding.ensureInitialized(); + _prefs = await SharedPreferences.getInstance(); + } } @override - Future getItem({required String key}) async { - await _initializationCompleter.future; + Future getItem(String key) async { + if (_useWebLocalStorage) { + // Despite its name, it just accesses the local storage with the given key + return web.accessToken(key); + } return _prefs.getString(key); } @override - Future removeItem({required String key}) async { - await _initializationCompleter.future; + Future removeItem(String key) async { + if (_useWebLocalStorage) { + // Despite its name, it just removes the item from local storage with the + // given key + return web.removePersistedSession(key); + } await _prefs.remove(key); } @override - Future setItem({required String key, required String value}) async { - await _initializationCompleter.future; + Future setItem(String key, String value) async { + if (_useWebLocalStorage) { + // Despite its name, it just sets the item in local storage with the given + // key and value + return web.persistSession(key, value); + } await _prefs.setString(key, value); } } + +/// Combines the storage for pkce and session into one. +/// +/// Previously the session got stored by [SupabaseAuth] and the pkce flow by +/// [GoTrueClient] in a separate storage and with different interface. +/// This combines both into one. +/// +/// This introduces another level of abstraction for the actual +/// session storage, but is necessary to prevent breaking changes. +class PkceAndSessionLocalStorage extends GotrueAsyncStorage { + final LocalStorage sessionLocalStorage; + final GotrueAsyncStorage pkceAsyncStorage; + + PkceAndSessionLocalStorage(this.sessionLocalStorage, this.pkceAsyncStorage); + @override + Future initialize() async { + await sessionLocalStorage.initialize(); + await pkceAsyncStorage.initialize(); + super.initialize(); + } + + @override + Future getItem(String key) { + if (key.endsWith("-code-verifier")) { + return pkceAsyncStorage.getItem(key); + } else { + return sessionLocalStorage.accessToken(); + } + } + + @override + Future removeItem(String key) async { + if (key.endsWith("-code-verifier")) { + await pkceAsyncStorage.removeItem(key); + } else { + await sessionLocalStorage.removePersistedSession(); + } + } + + @override + Future setItem(String key, String value) async { + if (key.endsWith("-code-verifier")) { + await pkceAsyncStorage.setItem(key, value); + } else { + await sessionLocalStorage.persistSession(value); + } + } +} diff --git a/packages/supabase_flutter/lib/src/supabase.dart b/packages/supabase_flutter/lib/src/supabase.dart index c2ce4e3c3..aed7a469b 100644 --- a/packages/supabase_flutter/lib/src/supabase.dart +++ b/packages/supabase_flutter/lib/src/supabase.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:async/async.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart'; @@ -50,30 +49,28 @@ class Supabase { /// Initialize the current supabase instance /// /// This must be called only once. If called more than once, an - /// [AssertionError] is thrown + /// [AssertionError] is thrown. + /// (after calling [dispose], [initialize] can be called again) /// /// [url] and [publishableKey] can be found on your Supabase dashboard. /// Use the `publishable` (anon) key here — never the secret key in a /// Flutter app. /// - /// You can access none public schema by passing different [schema]. - /// /// Default headers can be overridden by specifying [headers]. /// - /// Pass [localStorage] to override the default local storage option used to - /// persist auth. - /// /// Custom http client can be used by passing [httpClient] parameter. /// - /// [storageRetryAttempts] specifies how many retry attempts there should be - /// to upload a file to Supabase storage when failed due to network - /// interruption. + /// [realtimeClientOptions], [authOptions], [storageOptions], + /// [postgrestOptions] specify different options you can pass to + /// [RealtimeClient], [GoTrueClient], [SupabaseStorageClient], + /// [PostgrestClient]. /// - /// Set [authFlowType] 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 [pkceAsyncStorage] to override the behavior. + /// [accessToken] Optional function for using a third-party authentication system with Supabase. + /// The function should return an access token or ID token (JWT) by obtaining + /// it from the third-party auth client library. Note that this function may be + /// called concurrently and many times. Use memoization and locking techniques + /// if this is not supported by the client libraries. When set, the `auth` + /// namespace of the Supabase client cannot be used. /// /// If [debug] is set to `true`, debug logs will be printed in debug console. Default is `kDebugMode`. static Future initialize({ @@ -117,19 +114,34 @@ class Supabase { _log.config("Initialize Supabase v$version"); + // ignore: deprecated_member_use if (authOptions.pkceAsyncStorage == null) { authOptions = authOptions.copyWith( pkceAsyncStorage: SharedPreferencesGotrueAsyncStorage(), ); } + // ignore: deprecated_member_use_from_same_package if (authOptions.localStorage == null) { authOptions = authOptions.copyWith( localStorage: SharedPreferencesLocalStorage( persistSessionKey: "sb-${Uri.parse(url).host.split(".").first}-auth-token", + // For now we don't set the above key that is used by supabase-js too + // as [AuthClientOptions.storageKey], because this would change + // the key for exsting pkce items. For v3 we should change this. ), ); } + + if (authOptions.asyncStorage == null) { + authOptions = authOptions.copyWith( + asyncStorage: PkceAndSessionLocalStorage( + // ignore: deprecated_member_use_from_same_package + authOptions.localStorage!, + // ignore: deprecated_member_use + authOptions.pkceAsyncStorage!, + )); + } _instance._init( url, effectiveKey, @@ -146,11 +158,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()); } _log.info('***** Supabase init completed *****'); @@ -175,9 +182,6 @@ class Supabase { bool _debugEnable = false; - /// Wraps the `recoverSession()` call so that it can be terminated when `dispose()` is called - late CancelableOperation _restoreSessionCancellableOperation; - // Listener for app lifecycle events to handle Realtime reconnection. AppLifecycleListener? _lifecycleListener; @@ -195,7 +199,6 @@ class Supabase { /// Dispose the instance to free up resources. Future dispose() async { _targetLifecycleState = null; - await _restoreSessionCancellableOperation.cancel(); _logSubscription?.cancel(); client.dispose(); _instance._supabaseAuth?.dispose(); diff --git a/packages/supabase_flutter/lib/src/supabase_auth.dart b/packages/supabase_flutter/lib/src/supabase_auth.dart index 4560e2d81..e8ca37cb3 100644 --- a/packages/supabase_flutter/lib/src/supabase_auth.dart +++ b/packages/supabase_flutter/lib/src/supabase_auth.dart @@ -51,7 +51,6 @@ import 'package:url_launcher/url_launcher.dart'; class SupabaseAuth with WidgetsBindingObserver { static WidgetsBinding? get _widgetsBindingInstance => WidgetsBinding.instance; - late LocalStorage _localStorage; late AuthFlowType _authFlowType; /// Whether to automatically refresh the token @@ -62,8 +61,6 @@ class SupabaseAuth with WidgetsBindingObserver { /// throughout your app's life. static bool _initialDeeplinkIsHandled = false; - StreamSubscription? _authSubscription; - StreamSubscription? _deeplinkSubscription; final _appLinks = AppLinks(); @@ -80,69 +77,29 @@ class SupabaseAuth with WidgetsBindingObserver { Future initialize({ required FlutterAuthClientOptions options, }) async { - _localStorage = options.localStorage!; _authFlowType = options.authFlowType; _autoRefreshToken = options.autoRefreshToken; - _authSubscription = Supabase.instance.client.auth.onAuthStateChange.listen( - (data) { - _onAuthStateChange(data.event, data.session); - }, - onError: (error, stackTrace) { - // Errors are already logged by GoTrueClient.notifyException before - // being added to the stream. The empty handler prevents them from - // being rethrown as unhandled zone errors. - }, - ); + _widgetsBindingInstance?.addObserver(this); - 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) { - _log.warning( - 'Error while setting initial session', error, stackTrace); - } + // If `persistSession` is set to true, the GoTrueClient will attempt to + // restore the session from the provided storage. + if (options.persistSession) { + try { + // We wait for the first event from onAuthStateChange to ensure that the + // initial session is either restored or an exception is emitted. + // + // This ensures that the initial session is ready after the + // Supabase.initialize() future completes. + await Supabase.instance.client.auth.onAuthStateChange.first; + } catch (e) { + // No need to log the error here, since the auth client already logs it + // and the user receives it through the onAuthStateChange stream too. } } - if (shouldEmitInitialSession) { - Supabase.instance.client.auth - // ignore: invalid_use_of_internal_member - .notifyAllSubscribers(AuthChangeEvent.initialSession); - } - _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) { - _log.warning(error.message, error, stackTrace); - } catch (error, stackTrace) { - _log.warning("Error while recovering session", error, stackTrace); - } } /// Dispose the instance to free up resources @@ -150,7 +107,6 @@ class SupabaseAuth with WidgetsBindingObserver { if (!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) { _initialDeeplinkIsHandled = false; } - _authSubscription?.cancel(); _stopDeeplinkObserver(); _widgetsBindingInstance?.removeObserver(this); } @@ -171,14 +127,6 @@ class SupabaseAuth with WidgetsBindingObserver { } } - void _onAuthStateChange(AuthChangeEvent event, Session? session) { - if (session != null) { - _localStorage.persistSession(jsonEncode(session.toJson())); - } else if (event == AuthChangeEvent.signedOut) { - _localStorage.removePersistedSession(); - } - } - /// If _authCallbackUrlHost not init, we treat all deep links as auth callback bool _isAuthCallbackDeeplink(Uri uri) { return (uri.fragment.contains('access_token') && diff --git a/packages/supabase_flutter/test/auth_test.dart b/packages/supabase_flutter/test/auth_test.dart index 8fd422900..664eb79e2 100644 --- a/packages/supabase_flutter/test/auth_test.dart +++ b/packages/supabase_flutter/test/auth_test.dart @@ -88,7 +88,7 @@ void main() { }); }); - group('Session recovery', () { + group('Session recovery with deprecated local storage', () { test('handles corrupted session data gracefully', () async { final corruptedStorage = MockExpiredStorage(); @@ -124,5 +124,36 @@ void main() { expect(Supabase.instance.client.auth.currentSession, isNull); }); }); + + group('Session recovery with new async storage', () { + test('handles corrupted session data gracefully', () async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + authOptions: FlutterAuthClientOptions( + asyncStorage: MockExpiredAsyncStorage(), + ), + ); + + // MockExpiredAsyncStorage 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 { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + authOptions: FlutterAuthClientOptions( + asyncStorage: MockEmptyAsyncStorage(), + ), + ); + + // Should handle empty storage gracefully + expect(Supabase.instance.client.auth.currentSession, isNull); + }); + }); }); } diff --git a/packages/supabase_flutter/test/deep_link_test.dart b/packages/supabase_flutter/test/deep_link_test.dart index 666b55d38..8df99dc4f 100644 --- a/packages/supabase_flutter/test/deep_link_test.dart +++ b/packages/supabase_flutter/test/deep_link_test.dart @@ -28,9 +28,7 @@ void main() { authOptions: FlutterAuthClientOptions( localStorage: MockEmptyLocalStorage(), pkceAsyncStorage: MockAsyncStorage() - ..setItem( - key: 'supabase.auth.token-code-verifier', - value: 'raw-code-verifier'), + ..setItem('supabase.auth.token-code-verifier', 'raw-code-verifier'), ), ); }); diff --git a/packages/supabase_flutter/test/storage_test.dart b/packages/supabase_flutter/test/storage_test.dart index e41a4c8e0..b52c34d92 100644 --- a/packages/supabase_flutter/test/storage_test.dart +++ b/packages/supabase_flutter/test/storage_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -86,36 +87,44 @@ void main() { // Set up fake shared preferences SharedPreferences.setMockInitialValues({}); asyncStorage = SharedPreferencesGotrueAsyncStorage(); + await asyncStorage.initialize(); // Allow for initialization to complete await Future.delayed(const Duration(milliseconds: 100)); }); test('setItem stores value for key', () async { - await asyncStorage.setItem(key: testKey, value: testValue); - final prefs = await SharedPreferences.getInstance(); - final storedValue = prefs.getString(testKey); - expect(storedValue, testValue); + await asyncStorage.setItem(testKey, testValue); + if (kIsWeb) { + // On web, the value is stored in localStorage, so we need to access it directly + final storedValue = await asyncStorage.getItem(testKey); + expect(storedValue, testValue); + return; + } else { + final prefs = await SharedPreferences.getInstance(); + final storedValue = prefs.getString(testKey); + expect(storedValue, testValue); + } }); test('getItem returns null when no value exists', () async { - final result = await asyncStorage.getItem(key: 'non_existent_key'); + final result = await asyncStorage.getItem('non_existent_key'); expect(result, null); }); test('getItem returns value when value exists', () async { - await asyncStorage.setItem(key: testKey, value: testValue); - final result = await asyncStorage.getItem(key: testKey); + await asyncStorage.setItem(testKey, testValue); + final result = await asyncStorage.getItem(testKey); expect(result, 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 asyncStorage.setItem(testKey, testValue); + expect(await asyncStorage.getItem(testKey), testValue); // Then remove it - await asyncStorage.removeItem(key: testKey); - expect(await asyncStorage.getItem(key: testKey), null); + await asyncStorage.removeItem(testKey); + expect(await asyncStorage.getItem(testKey), null); }); }); }); diff --git a/packages/supabase_flutter/test/widget_test_stubs.dart b/packages/supabase_flutter/test/widget_test_stubs.dart index fc3c4789e..5ce051a13 100644 --- a/packages/supabase_flutter/test/widget_test_stubs.dart +++ b/packages/supabase_flutter/test/widget_test_stubs.dart @@ -64,6 +64,23 @@ class MockExpiredStorage extends LocalStorage { Future removePersistedSession() async {} } +/// Async storage that returns an expired session +class MockExpiredAsyncStorage extends GotrueAsyncStorage { + @override + Future initialize() async {} + @override + Future getItem(String key) async { + return getSessionData(DateTime.now().subtract(const Duration(hours: 1))) + .sessionString; + } + + @override + Future removeItem(String key) async {} + + @override + Future setItem(String key, String value) async {} +} + class MockLocalStorage extends LocalStorage { @override Future initialize() async {} @@ -94,6 +111,17 @@ class MockEmptyLocalStorage extends LocalStorage { Future removePersistedSession() async {} } +class MockEmptyAsyncStorage extends GotrueAsyncStorage { + @override + Future initialize() async {} + @override + Future getItem(String key) async => null; + @override + Future removeItem(String key) async {} + @override + Future setItem(String key, String value) async {} +} + /// Registers the mock handler for app_links /// /// Returns the [EventChannel] used to mock the incoming links. @@ -137,17 +165,17 @@ class MockAsyncStorage extends GotrueAsyncStorage { final Map _map = {}; @override - Future getItem({required String key}) async { + Future getItem(String key) async { return _map[key]; } @override - Future removeItem({required String key}) async { + Future removeItem(String key) async { _map.remove(key); } @override - Future setItem({required String key, required String value}) async { + Future setItem(String key, String value) async { _map[key] = value; } }