supabase-flutter
supabase-flutter copied to clipboard
Custom LocalStorage logs out user instead of refreshing session
Describe the bug
I need to maintain a Custom LocalStorage and followed the description here: https://pub.dev/documentation/supabase_flutter/latest/#a-idcustom-localstorageacustom-localstorage
I am encountering a recurring issue in my iOS Flutter application that integrates Supabase with custom LocalStorage for authentication. Specifically, when attempting to automatically recover a user session after the application has been in the background, the refresh token fails with an "Invalid Refresh Token: Already Used" error. This error is consistently triggering a sign-out event, effectively logging the user out and hindering session persistence across app launches or background returns.
To Reproduce Steps to reproduce the behavior:
- Log in to the application to initiate a session with Supabase.
- Send the application to the background for an extended period or until the access token is likely expired.
- Resume the application, triggering the automatic session recovery process.
- Observe that instead of refreshing the session, an error occurs, and the user is signed out.
Expected behavior The application should silently refresh the session using the stored refresh token when the access token has expired, without any intervention from the user, maintaining the user's logged-in state across app uses. This works for the default localstorage of Supabase.
Actual beheavior When attempting to refresh the session, the application signs out and throws an "Invalid Refresh Token: Already Used" error, resulting in the user being signed out. The relevant log outputs are as follows:
flutter: **** onAuthStateChange: AuthChangeEvent.signedOut
flutter: Auth state change detected
flutter: AuthException(message: Invalid Refresh Token: Already Used, statusCode: 400)
flutter: #0 GotrueFetch.request (package:gotrue/src/fetch.dart:99:7)
flutter: <asynchronous suspension>
flutter: #1 GoTrueClient._callRefreshToken (package:gotrue/src/gotrue_client.dart:1087:24)
flutter: <asynchronous suspension>
flutter: #2 GoTrueClient.recoverSession (package:gotrue/src/gotrue_client.dart:928:16)
flutter: <asynchronous suspension>
flutter: #3 SupabaseAuth._recoverSupabaseSession (package:supabase_flutter/src/supabase_auth.dart:127:7)
flutter: <asynchronous suspension>
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: AuthException(message: Invalid Refresh Token: Already Used, statusCode: 400)
#0 GotrueFetch.request (package:gotrue/src/fetch.dart:99:7)
<asynchronous suspension>
#1 GoTrueClient._callRefreshToken (package:gotrue/src/gotrue_client.dart:1087:24)
<asynchronous suspension>
#2 GoTrueClient.recoverSession (package:gotrue/src/gotrue_client.dart:928:16)
<asynchronous suspension>
#3 SupabaseAuth._recoverSupabaseSession (package:supabase_flutter/src/supabase_auth.dart:127:7)
<asynchronous suspension>
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: AuthException(message: Invalid Refresh Token: Already Used, statusCode: 400)
#0 GotrueFetch.request (package:gotrue/src/fetch.dart:99:7)
<asynchronous suspension>
#1 GoTrueClient._callRefreshToken (package:gotrue/src/gotrue_client.dart:1087:24)
<asynchronous suspension>
#2 GoTrueClient.recoverSession (package:gotrue/src/gotrue_client.dart:928:16)
<asynchronous suspension>
#3 SupabaseAuth._recoverSupabaseSession (package:supabase_flutter/src/supabase_auth.dart:127:7)
<asynchronous suspension>
Version (please complete the following information): ├── supabase_flutter 2.3.4 │ ├── supabase 2.0.8 │ │ ├── functions_client 2.0.0 │ │ ├── gotrue 2.5.1 │ │ ├── postgrest 2.1.1 │ │ ├── realtime_client 2.0.1 │ │ ├── storage_client 2.0.1
cc: @dshukertjr I saw you working on the custom LocalStorage implementation and maybe you can help :)
Ok I updated to supabase_flutter: ^2.5.1
after seeing the 2.5.0 changelog description and I cannot reproduce the bug anymore.
Could somebody confirm that that indeed fixed the custom LocalStorage implementation?
Update: Another interesting thing I noticed with the update is that I do not see the app continuously refreshing the tokens in the background anymore.
Update 2: Ok, was able to reproduce it again, but with a more informative error log.
flutter: **** onAuthStateChange: AuthChangeEvent.signedOut
flutter: Removing session data...
flutter: Auth state change detected
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Null check operator used on a null value
#0 AuthHttpClient.send (package:supabase/src/auth_http_client.dart:20:37)
<asynchronous suspension>
#1 BaseClient._sendUnstreamed (package:http/src/base_client.dart:93:32)
<asynchronous suspension>
#2 PostgrestBuilder._execute (package:postgrest/src/postgrest_builder.dart:130:20)
<asynchronous suspension>
#3 PostgrestBuilder.then (package:postgrest/src/postgrest_builder.dart:372:24)
<asynchronous suspension>
@micheltucker Whether your implementation of the custom local storage works fine or not depends on the implementation itself. The error you were encountering makes me think that there is something wrong with the implementation, and the application is not able to update the refresh token with the new one whenever it's issued.
The update on v2.5.0 may have reduced how often token refresh happens, but I don't think it fixed the root cause of what you were having trouble with. All of that, however, is just speculation, and we cannot confirm anything unless you share your implementation of your custom local storage.
@micheltucker Whether your implementation of the custom local storage works fine or not depends on the implementation itself. The error you were encountering makes me think that there is something wrong with the implementation, and the application is not able to update the refresh token with the new one whenever it's issued.
The update on v2.5.0 may have reduced how often token refresh happens, but I don't think it fixed the root cause of what you were having trouble with. All of that, however, is just speculation, and we cannot confirm anything unless you share your implementation of your custom local storage.
Makes sense. I really just implemented your template code:
class MySecureStorage extends LocalStorage {
final storage = FlutterSecureStorage();
@override
Future<void> initialize() async {}
@override
Future<String?> accessToken() async {
return storage.read(key: supabasePersistSessionKey);
}
@override
Future<bool> hasAccessToken() async {
return storage.containsKey(key: supabasePersistSessionKey);
}
@override
Future<void> persistSession(String persistSessionString) async {
return storage.write(key: supabasePersistSessionKey, value: persistSessionString);
}
@override
Future<void> removePersistedSession() async {
return storage.delete(key: supabasePersistSessionKey);
}
}
Future<void> initSupabase() async {
await Supabase.initialize(
url: supabaseURL,
anonKey: supabaseAnonKey,
authOptions: FlutterAuthClientOptions(
localStorage: MySecureStorage(),
),
);
}
and because I need to share it with another part of my app I modified it ever so slightly, but it happens with either code:
class MySecureStorage extends LocalStorage {
final FlutterSecureStorage storage = const FlutterSecureStorage();
@override
Future<void> initialize() async {}
@override
Future<String?> accessToken() async {
print("Attempting to retrieve session data...");
return storage.read(
key: supabasePersistSessionKey,
iOptions: const IOSOptions(
accountName: keychainService, groupId: keychainSharingGroup));
}
@override
Future<bool> hasAccessToken() async {
print("Checking if session data exists...");
return storage.containsKey(
key: supabasePersistSessionKey,
iOptions: const IOSOptions(
accountName: keychainService, groupId: keychainSharingGroup));
}
@override
Future<void> persistSession(String persistSessionString) async {
print("Persisting session string: $persistSessionString");
return storage.write(
key: supabasePersistSessionKey,
value: persistSessionString,
iOptions: const IOSOptions(
accountName: keychainService, groupId: keychainSharingGroup));
}
@override
Future<void> removePersistedSession() async {
print("Removing session data...");
return storage.delete(
key: supabasePersistSessionKey,
iOptions: const IOSOptions(
accountName: keychainService, groupId: keychainSharingGroup));
}
}
Ok so it must be something with the keychain implementation, as I now used this pub import 'package:shared_preference_app_group/shared_preference_app_group.dart';
and extended it like so:
class MySecureStorage extends LocalStorage {
@override
Future<void> initialize() async {
print("Initializing shared preferences with suite name...");
await SharedPreferenceAppGroup.setAppGroup(appGroupID);
}
@override
Future<String?> accessToken() async {
print("Attempting to retrieve session data...");
return SharedPreferenceAppGroup.getString(supabasePersistSessionKey);
}
@override
Future<bool> hasAccessToken() async {
print("Checking if session data exists...");
var token = await SharedPreferenceAppGroup.getString(supabasePersistSessionKey);
return token != null;
}
@override
Future<void> persistSession(String persistSessionString) async {
print("Persisting session string: $persistSessionString");
await SharedPreferenceAppGroup.setString(supabasePersistSessionKey, persistSessionString);
}
@override
Future<void> removePersistedSession() async {
print("Removing session data...");
await SharedPreferenceAppGroup.remove(supabasePersistSessionKey);
}
}
Would be great if somebody could look at the keychain implementation, as it is also the main example for the custom LocalStorage. I spent quite a bit of time trying to figure out where the issue is, but I now believe that the issue actually is with the Keychain/Supabase implementation.
Did you succeed?
I would like to share the authentication between my main app and an extension target and custom auth storage seems to be a potential solution. Currently I shared the session details via the keychain and use a refreshSession supabase function (it uses the refresh token) to login via the extension, but when back in my main app, the refresh token is thus invalid..