auth0 / react-native-auth0

React Native toolkit for Auth0 API
https://auth0.com
MIT License
480 stars 206 forks source link

`webAuth.authorize` promise not completing on an Android deep link #785

Closed rick-lannan-upgrowth closed 10 months ago

rick-lannan-upgrowth commented 11 months ago

Checklist

Description

In v3 versions of the package, there is an Android bug where the webAuth.authorize promise never completes when the auth0 overlay gets hidden when the app is minimised and reopened via a deep link or the app icon. The result of this is code awaiting the auth0 authorize action just sits waiting forever

I have managed to patch the issue and would like the patch applied back to the source code in the next release. Here is the patch to src/webauth/agent.ts that resolves the issue:

import { AppState, AppStateStatus, NativeEventSubscription, NativeModules, Platform } from 'react-native';
import { Credentials } from 'src/types';
import { _ensureNativeModuleIsInitialized } from '../utils/nativeHelper';
import {
  AgentLoginOptions,
  AgentLogoutOptions,
  AgentParameters,
  Auth0Module,
} from 'src/internal-types';

type AuthError = {
  code: string
  message: string
}
const A0Auth0: Auth0Module = NativeModules.A0Auth0;
class Agent {
  async login(
    parameters: AgentParameters,
    options: AgentLoginOptions
  ): Promise<Credentials> {
    if (!NativeModules.A0Auth0) {
      return Promise.reject(
        new Error(
          'Missing NativeModule. React Native versions 0.60 and up perform auto-linking. Please see https://github.com/react-native-community/cli/blob/master/docs/autolinking.md.'
        )
      );
    }
    await _ensureNativeModuleIsInitialized(
      A0Auth0,
      parameters.clientId,
      parameters.domain
    );
    let scheme = this.getScheme(
      options.useLegacyCallbackUrl ?? false,
      options.customScheme
    );
    let redirectUri = this.callbackUri(parameters.domain, scheme);
    // Workaround to force the authorize promise to complete when the app becomes active on Android.
    // This is needed as the overlay is dismissed but the promise never completes on Android
    // when the app is backgrounded and relaunched via a deep link or the app icon
    const promiseWrapper = async (authPromise: Promise<Credentials>) => {
      const DELAY = 500;
      let forcePromiseRejectOnAuthOverlayDismiss: (reason?: AuthError) => void = () => {};
      let subscription: NativeEventSubscription | undefined = undefined;
      let previousAppState = AppState.currentState
      subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
        if (Platform.OS === 'android' && previousAppState === 'background' && nextAppState === 'active') {
          setTimeout(() => {
            forcePromiseRejectOnAuthOverlayDismiss({
              code: 'auth_overlay_dismissed_by_os',
              message: 'The auth was dismissed by the system'
            });
            forcePromiseRejectOnAuthOverlayDismiss = () => {};
            subscription?.remove();
          }, DELAY);
        }
        previousAppState = nextAppState
      })
      return Promise.race([
        authPromise,
        new Promise<Credentials>((resolve, reject) => (forcePromiseRejectOnAuthOverlayDismiss = reject))
      ]);
    }

    return promiseWrapper(A0Auth0.webAuth(
      scheme,
      redirectUri,
      options.state,
      options.nonce,
      options.audience,
      options.scope,
      options.connection,
      options.maxAge ?? 0,
      options.organization,
      options.invitationUrl,
      options.leeway ?? 0,
      options.ephemeralSession ?? false,
      options.additionalParameters ?? {}
    ));
  }

  async logout(
    parameters: AgentParameters,
    options: AgentLogoutOptions
  ): Promise<void> {
    if (!NativeModules.A0Auth0) {
      return Promise.reject(
        new Error(
          'Missing NativeModule. React Native versions 0.60 and up perform auto-linking. Please see https://github.com/react-native-community/cli/blob/master/docs/autolinking.md.'
        )
      );
    }
    let federated = options.federated ?? false;
    let scheme = this.getScheme(
      options.useLegacyCallbackUrl ?? false,
      options.customScheme
    );
    let redirectUri = this.callbackUri(parameters.domain, scheme);
    await _ensureNativeModuleIsInitialized(
      NativeModules.A0Auth0,
      parameters.clientId,
      parameters.domain
    );

    return A0Auth0.webAuthLogout(scheme, federated, redirectUri);
  }

  private getScheme(
    useLegacyCustomSchemeBehaviour: boolean,
    customScheme?: string
  ) {
    let scheme = NativeModules.A0Auth0.bundleIdentifier.toLowerCase();
    if (!useLegacyCustomSchemeBehaviour) {
      scheme = scheme + '.auth0';
    }
    return customScheme ?? scheme;
  }

  private callbackUri(domain: string, scheme: string) {
    let bundleIdentifier = NativeModules.A0Auth0.bundleIdentifier.toLowerCase();
    return `${scheme}://${domain}/${Platform.OS}/${bundleIdentifier}/callback`;
  }
}

export default Agent;

Reproduction

  1. Trigger an authorize call that opens to auth0 overlay on an Android device
  2. While the overlay is still open, minimize the app to the the background
  3. Relaunch the app via the app icon
  4. The OS dismisses the web view overlay but the code is still awaiting the promise resolution, the patch resolves this

Additional context

No response

react-native-auth0 version

3+

React Native version

-

Expo version

-

Platform

Android

Platform version(s)

-

poovamraj commented 11 months ago

@rick-lannan-upgrowth I tried to reproduce the issue you mentioned and I am not able to. Can you try to reproduce this in our Sample App so that we can take a look.

https://github.com/auth0/react-native-auth0/assets/15910425/38c3dadb-8cf2-4deb-9d9e-a405d7415997

rick-lannan-upgrowth commented 10 months ago

If you look in your video you can see the overlay is getting shut by the system when you background and relaunch the app. What's not immediately evident is that the promise is still awaiting. When you click the LOG IN button again you are just launching a new promise. This is a problem in our application as we have a loader screen that is cleared when the promise returns. If you modify the sample app to include as awaiting state you can see the problem

image

auth0-android-awaiting-bug.webm

It should be noted that this bug was introduced in v3 of the package as there used to be onActivityResult code that would handle this

poovamraj commented 10 months ago

That makes more sense @rick-lannan-upgrowth Do you think opening the authentication page as part of the application's task instead of a seperate task (window) would solve this issue. This is what happens in our Android SDK. Since our RNA SDK is a wrapper around the Android SDK we can check why this is happening in a seperate window.

I have added a sample on how our Android SDK handles this here

https://github.com/auth0/react-native-auth0/assets/15910425/ad0ee9af-4f48-48b1-b09f-4dfe90e3695e

rick-lannan-upgrowth commented 10 months ago

Hi, I'm not sure I quite understand what you mean here but I'm assuming this means that opening the auth overlay won't trigger a change in app focus like it currently does / the OS won't close the overlay when backgrounded and relaunched? This solution would be ideal. Either way, it looks like from your example video that the bug is not reproducible with this solution

poovamraj commented 10 months ago

@rick-lannan-upgrowth Yes that would be the ideal solution. We will try to figure out why this is not happening in our RNA SDK where as it is the default behaviour in our Android SDK which the RNA SDK depends upon.

jonlowrey commented 10 months ago

Checklist

Description

In v3 versions of the package, there is an Android bug where the webAuth.authorize promise never completes when the auth0 overlay gets hidden when the app is minimised and reopened via a deep link or the app icon. The result of this is code awaiting the auth0 authorize action just sits waiting forever

I have managed to patch the issue and would like the patch applied back to the source code in the next release. Here is the patch to src/webauth/agent.ts that resolves the issue:

import { AppState, AppStateStatus, NativeEventSubscription, NativeModules, Platform } from 'react-native';
import { Credentials } from 'src/types';
import { _ensureNativeModuleIsInitialized } from '../utils/nativeHelper';
import {
  AgentLoginOptions,
  AgentLogoutOptions,
  AgentParameters,
  Auth0Module,
} from 'src/internal-types';

type AuthError = {
  code: string
  message: string
}
const A0Auth0: Auth0Module = NativeModules.A0Auth0;
class Agent {
  async login(
    parameters: AgentParameters,
    options: AgentLoginOptions
  ): Promise<Credentials> {
    if (!NativeModules.A0Auth0) {
      return Promise.reject(
        new Error(
          'Missing NativeModule. React Native versions 0.60 and up perform auto-linking. Please see https://github.com/react-native-community/cli/blob/master/docs/autolinking.md.'
        )
      );
    }
    await _ensureNativeModuleIsInitialized(
      A0Auth0,
      parameters.clientId,
      parameters.domain
    );
    let scheme = this.getScheme(
      options.useLegacyCallbackUrl ?? false,
      options.customScheme
    );
    let redirectUri = this.callbackUri(parameters.domain, scheme);
    // Workaround to force the authorize promise to complete when the app becomes active on Android.
    // This is needed as the overlay is dismissed but the promise never completes on Android
    // when the app is backgrounded and relaunched via a deep link or the app icon
    const promiseWrapper = async (authPromise: Promise<Credentials>) => {
      const DELAY = 500;
      let forcePromiseRejectOnAuthOverlayDismiss: (reason?: AuthError) => void = () => {};
      let subscription: NativeEventSubscription | undefined = undefined;
      let previousAppState = AppState.currentState
      subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
        if (Platform.OS === 'android' && previousAppState === 'background' && nextAppState === 'active') {
          setTimeout(() => {
            forcePromiseRejectOnAuthOverlayDismiss({
              code: 'auth_overlay_dismissed_by_os',
              message: 'The auth was dismissed by the system'
            });
            forcePromiseRejectOnAuthOverlayDismiss = () => {};
            subscription?.remove();
          }, DELAY);
        }
        previousAppState = nextAppState
      })
      return Promise.race([
        authPromise,
        new Promise<Credentials>((resolve, reject) => (forcePromiseRejectOnAuthOverlayDismiss = reject))
      ]);
    }

    return promiseWrapper(A0Auth0.webAuth(
      scheme,
      redirectUri,
      options.state,
      options.nonce,
      options.audience,
      options.scope,
      options.connection,
      options.maxAge ?? 0,
      options.organization,
      options.invitationUrl,
      options.leeway ?? 0,
      options.ephemeralSession ?? false,
      options.additionalParameters ?? {}
    ));
  }

  async logout(
    parameters: AgentParameters,
    options: AgentLogoutOptions
  ): Promise<void> {
    if (!NativeModules.A0Auth0) {
      return Promise.reject(
        new Error(
          'Missing NativeModule. React Native versions 0.60 and up perform auto-linking. Please see https://github.com/react-native-community/cli/blob/master/docs/autolinking.md.'
        )
      );
    }
    let federated = options.federated ?? false;
    let scheme = this.getScheme(
      options.useLegacyCallbackUrl ?? false,
      options.customScheme
    );
    let redirectUri = this.callbackUri(parameters.domain, scheme);
    await _ensureNativeModuleIsInitialized(
      NativeModules.A0Auth0,
      parameters.clientId,
      parameters.domain
    );

    return A0Auth0.webAuthLogout(scheme, federated, redirectUri);
  }

  private getScheme(
    useLegacyCustomSchemeBehaviour: boolean,
    customScheme?: string
  ) {
    let scheme = NativeModules.A0Auth0.bundleIdentifier.toLowerCase();
    if (!useLegacyCustomSchemeBehaviour) {
      scheme = scheme + '.auth0';
    }
    return customScheme ?? scheme;
  }

  private callbackUri(domain: string, scheme: string) {
    let bundleIdentifier = NativeModules.A0Auth0.bundleIdentifier.toLowerCase();
    return `${scheme}://${domain}/${Platform.OS}/${bundleIdentifier}/callback`;
  }
}

export default Agent;

Reproduction

  1. Trigger an authorize call that opens to auth0 overlay on an Android device
  2. While the overlay is still open, minimize the app to the the background
  3. Relaunch the app via the app icon
  4. The OS dismisses the web view overlay but the code is still awaiting the promise resolution, the patch resolves this

Additional context

No response

react-native-auth0 version

3+

React Native version

Expo version

Platform

Android

Platform version(s)

@rick-lannan-upgrowth Thank you for providing your patch solution.

I'm wondering if you have deep linking already set up in you application and if you needed to change any redirect setups in your AndroidManifest files to get the redirect to work in the first place?

rick-lannan-upgrowth commented 10 months ago

Hi @jonlowrey, just to be clear the promiseWrapper patch I have suggested is not an ideal solution. Fixing the underlying issue where the auth window gets dismissed when backgrounding and relaunching the app is the correct solution here and it seems what @poovamraj suggested will do just that

As for your questions though, we do have deep linking in our app but there is no specific auth related redirect configuration in place. To be honest, I'm not even really sure by what you mean by 'redirect' though - the auth related deep link we have on the email verified confirmation page on our website simply relaunches the app without any auth deep link related config or code - the code is simply awaiting the authorize promise to complete. But as you can see from my previous comments I was able to reproduce the issue in the sample app that doesn't have deep linking by simply backgrounding and relaunching the app from the app icon. My theory is this triggers an app navigation event, similar to a deep link, which causes the auth window to close prematurely as the app window has received focus. From what @poovamraj has suggested this is likely because the auth is running in a different task/window

It's also expected you wouldn't get credentials back when this premature auth window dismiss bug is triggered as the auth didn't complete. If you look in the previous v2 version of the Android module (https://github.com/auth0/react-native-auth0/blob/v2/android/src/main/java/com/auth0/react/A0Auth0Module.java) there is actually code in onActivityResult that forces an a0.session.user_cancelled promise rejection in this scenario, which was missing from the v3 implementation. This wasn't an ideal solution though as it resulted in the auth being cancelled without credentials returned but at least it caused the promise to complete. We actually have some code in our app to attempt a silent login when we receive the a0.session.user_cancelled event to streamline the registration process for users so they weren't forced to re-authenticate manually but this was definitely a hack. The intention of the patch I suggested was to implement something similar in the v3 implementation but I used the different error code of auth_overlay_dismissed_by_os so we could differentiate between a promise rejection due to this bug and a genuine user cancellation, which we weren't able to do with the v2 implementation. However, if possible, a solution to stop the auth window from prematurely closing would be the ideal solution as that would allow the user to complete the auth regardless of app relaunch from background events

poovamraj commented 10 months ago

@rick-lannan-upgrowth This issue is happening because react-native by default uses android:launchMode="singleTask"

Overriding with the following to your AndroidManifest.xml seems to fix this issue

  <application>
      <activity
          android:name=".MainActivity"
          android:label="@string/app_name"
          android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
          android:windowSoftInputMode="adjustResize"
          android:exported="true">
          <intent-filter>
              <action android:name="android.intent.action.MAIN" />
              <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
      </activity>
  </application>

But as of now this is not the suggested solution. We will analyse more on how we can fix this and update in this thread.

poovamraj commented 10 months ago

onNewIntent is called back when a new task is created and there is an existing activity alive and we can use that to cancel the promise. This should be the cleanest solution and we will work on this and provide you with the fix soon.

We never faced this in our Android SDK as using singleTask is not recommended for most applications and only for special edge cases.

poovamraj commented 10 months ago

@rick-lannan-upgrowth @jonlowrey I have implemented a fix for this issue in this PR. Can you both check it out and let us know your feedback?

rick-lannan-upgrowth commented 10 months ago

I've provided feedback on the PR

poovamraj commented 10 months ago

We have merged the fix. We will be providing a new release soon with the fix. We will keep you updated on the thread about the release. We will close this for now. Feel free to comment here and we can reopen it if required.

rick-lannan-upgrowth commented 10 months ago

Hi @poovamraj, do you have a rough idea when the new release is going out?

poovamraj commented 9 months ago

@rick-lannan-upgrowth the release is out. We have implemented more improvements and some fixes along with this.

mohanrajmarudhachalam commented 2 months ago

Error: Missing NativeModule. React Native versions 0.60 and up perform auto-linking. Please see https://github.com/react-native-community/cli/blob/master/docs/autolinking.md.

Many times i was tried getting this error