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
656 stars 154 forks source link

Handling initial deeplink in password recovery #937

Closed bruiken closed 1 week ago

bruiken commented 3 weeks ago

Is your feature request related to a problem? Please describe. When implementing the password recovery flow, we noticed that the initial deeplink handling does not yield for much customisability. The function _handleInitialUri() is called as await from the initialisation function of the Supabase client. If that initial deeplink is concerning a password recovery, then it seems to be impossible to catch that event in the supabase.auth.onAuthStateChange listener. The same holds for the error cases, which are never even brought to that stream (this has been pointed out by #664 before too). Yet in that case it wasn't about a password recovery but a magic link. For magic links this all is not that big of an issue, at the end of the day the user is authenticated and there is a session. For password recovery it to us seems impossible to direct the user to the "reset your password" screen when the app was fully closed.

Describe the solution you'd like We'd like to see a solution where it is made possible to detect password recovery and possible errors from initial deeplink handling. A solution in this most definitely extends to any event, not just password recovery.

Describe alternatives you've considered

Additional context Besides this, it's also quite difficult to catch errors specific to deeplink handling, our code now contains a number of checks based on the AuthException.message field. It would seem like a good idea to create more different error subclasses of AuthException so it is possible to do checks that are not reliant on error message changes.

If directions are given for how to possibly fix this, I'd be okay with submitting a PR. One possible avenue could be to allow giving the supabase.auth.onAuthStateChange in the Supabase.initialize function. I don't think that would be nice behaviour as listeners in different places get access to different events. Maybe a more specific event handler for the initial deeplink handling? Or then some way to get access to past events.

bruiken commented 3 weeks ago

After a bit further investigation in how we can do this, it seems this issue could be resolved by using AppLinks.allUriLinkStream instead of AppLinks.uriLinkStream. The former includes the initial link. This is different from the current implementation because this initial link will not be awaited and can thus be caught by our listeners.

A different solution for us would be if the deeplink integration had a bit more customisability. Allowing e.g. an enum option such as enum DeeplinkHandlingOption { all, none, nonInitial }. I don't really like this because this would just be duplicate implementation.

dshukertjr commented 2 weeks ago

So, to summarise, you are saying that the AuthChangeEvent.passwordRecovery event never fires when the app is launched from the deep link?

The initial link for password recovery events is handled like other events and should fire without issues. I have tested it with app_links v6.1.1 and supabase_flutter supabase_flutter v2.5.3, and can confirm I get a AuthChangeEvent.passwordRecovery event right after the AuthChangeEvent.initialSession event upon app launch.

bruiken commented 2 weeks ago

The initial link for password recovery events is handled like other events and should fire without issues. I have tested it with app_links v6.1.1 and supabase_flutter supabase_flutter v2.5.3, and can confirm I get a AuthChangeEvent.passwordRecovery event right after the AuthChangeEvent.initialSession event upon app launch.

@dshukertjr thanks for the reply! I indeed see the event getting fired. It is just that I see no way to actually receive that AuthChangeEvent.passwordRecovery in a listener, if it is caused by the initial deeplink handling. It seems impossible to create the subscription to the steam in time to get the event surfaced to our app logic.

dshukertjr commented 2 weeks ago

Hmm, you should be able to catch the passwordRecovery event within your app. I was able to listen to it with the following code:

Future<void> main() async {
  await Supabase.initialize(
    url: supabaseUrl,
    anonKey: supabaseKey,
  );

  runApp(const MyApp());
}

final supabase = Supabase.instance.client;

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Playground',
      home: HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  String? _userId;
  late final RealtimeChannel channel;

  @override
  void initState() {
    super.initState();

    supabase.auth.onAuthStateChange.listen((event) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(
        content: Text(event.event.name),
      ));
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(_userId ?? 'no user'),
            const SizedBox(height: 12),
            ElevatedButton(
              onPressed: () async {
                await supabase.auth.resetPasswordForEmail(
                  'email@example.com',
                  redirectTo: redirectTo,
                );
              },
              child: const Text('Press'),
            ),
          ],
        ),
      ),
    );
  }
}
bruiken commented 2 weeks ago

Hmm, you should be able to catch the passwordRecovery event within your app. I was able to listen to it with the following code:

@dshukertjr interestingly, I am running the same versions and do not see the events with the same code. I want to once again verify, I don't just mean starting the app from a paused/minimised state. I am only having issues with the app starting up fully (from a cold state) from a deeplink. This is tricky to test to legitimate flow because I don't see an easy way to set the initial deeplink when booting the app (I just set it manually in SupabaseAuth).

If the app is running in the background, I see the passwordRecovery event as expected. If the app is fully shut down I only get initialSession.

I'm putting the listener immediately after the supabase initialisation as follows:

Future<void> main() async {
  await Supabase.initialize(
    url: dotenv.get('SUPABASE_URL'),
    anonKey: dotenv.get('SUPABASE_ANON_KEY'),
    authOptions: const FlutterAuthClientOptions(
      authFlowType: AuthFlowType.pkce,
    ),
    realtimeClientOptions: const RealtimeClientOptions(
      logLevel: RealtimeLogLevel.debug,
    ),
  );

  supabase.auth.onAuthStateChange.listen(
    (event) {
      log(event.event.name);
    },
    onError: (e) {
      log(e.toString());
    },
  );

  runApp(...);
}

With this, I test handling an initial deeplink as follows:

  1. Reset password in app

  2. Click link in browser on non-simulator

  3. Copy the redirect uri to the app

  4. Paste it as the result of SupabaseAuth::_handleInitialUri() as something like this:

    Uri? uri;
      try {
        // before app_links 6.0.0
        uri = await (_appLinks as dynamic).getInitialAppLink();
      } on NoSuchMethodError catch (_) {
        // Needed to keep compatible with 5.0.0 and 6.0.0
        // https://pub.dev/packages/app_links/changelog
        // after app_links 6.0.0
        uri = await (_appLinks as dynamic).getInitialLink();
      }
    
      // temp hack to test password forget link that is not already used
      uri = Uri.parse('REDIRECT LINK FROM (3) HERE');
    
      if (uri != null) {
        await _handleDeeplink(uri);
      }
  5. stop and start the app

Then the output of my logging from the earlier snippet is as follows:

Xcode build done.                                           20.9s
Connecting to VM Service at ws://127.0.0.1:55185/LpjTqO-S8r8=/ws
flutter: ***** Supabase init completed Instance of 'Supabase'
flutter: ***** SupabaseDeepLinkingMixin startAuthObserver
flutter: ***** SupabaseAuthState handleDeeplink deeplink.test:?code=54cc8f0e-5292-46fa-8ae8-61cce09b4225
flutter: onReceivedAuthDeeplink uri: deeplink.test:?code=54cc8f0e-5292-46fa-8ae8-61cce09b4225
flutter: **** onAuthStateChange: AuthChangeEvent.passwordRecovery
flutter: {"access_token": ...
[log] initialSession
flutter: **** onAuthStateChange: AuthChangeEvent.initialSession
flutter: {"access_token":"...

I see the supabase event being fired, but it's not coming up in the listener. This makes sense as the listener subscribes after the whole processing of the deeplink is done (this seems to be the problem to me). Somehow it needs to be possible to get access to that stream during the initial deeplink handling.

If I were to keep the app paused in the background and then do the password reset redirection logic I see the following:

flutter: ***** SupabaseAuthState handleDeeplink deeplink.test:?code=4800a17f-b5ae-46ae-97cb-1153d44106d9
flutter: onReceivedAuthDeeplink uri: deeplink.test:?code=4800a17f-b5ae-46ae-97cb-1153d44106d9
flutter: **** onAuthStateChange: AuthChangeEvent.passwordRecovery
flutter: {"access_token"...
[log] passwordRecovery

Which makes sense to me.

All of this is just for the legimate flow (when the password recovery link is valid). If the link is NOT valid, all of the errors are catched by the SupabaseAuth logic, so we cannot even catch those errors in the auth stream subscription. This is an equally big problem to me, as we can then show nothing to the user.

dshukertjr commented 1 week ago

With this, I test handling an initial deeplink as follows:

I believe the reason why you are not seeing the password recovery event is because you are not testing it with the actual flow that the user goes through.

Just run through the password recovery flow like usual and the password recovery event will show up whether the app is terminated or not.

  1. Call the resetPasswordForEmail() from the app
  2. Terminate the app
  3. Open the email from the same phone/ emulator and click the link
  4. The app opens and password recovery event is observed after the initial session event.