supabase / supabase-flutter

Flutter integration for Supabase. This package makes it simple for developers to build secure and scalable products.
https://supabase.com/
MIT License
661 stars 154 forks source link

Custom LocalStorage logs out user instead of refreshing session #895

Open micheltucker opened 2 months ago

micheltucker commented 2 months ago

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:

  1. Log in to the application to initiate a session with Supabase.
  2. Send the application to the background for an extended period or until the access token is likely expired.
  3. Resume the application, triggering the automatic session recovery process.
  4. 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

micheltucker commented 2 months ago

cc: @dshukertjr I saw you working on the custom LocalStorage implementation and maybe you can help :)

micheltucker commented 2 months ago

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>
dshukertjr commented 2 months ago

@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 commented 2 months ago

@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));
  }
}
micheltucker commented 2 months ago

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.