aws-amplify / amplify-js

A declarative JavaScript library for application development using cloud services.
https://docs.amplify.aws/lib/q/platform/js
Apache License 2.0
9.43k stars 2.13k forks source link

CognitoUserSession.isValid() produces `Undefined is not a function` when called #10597

Closed iambryanwho closed 2 years ago

iambryanwho commented 2 years ago

Before opening, please confirm:

JavaScript Framework

React Native

Amplify APIs

Authentication

Amplify Categories

auth, api

Environment information

``` # Put output below this line System: OS: macOS 12.6 CPU: (8) x64 Apple M2 Memory: 26.73 MB / 16.00 GB Shell: 5.8.1 - /bin/zsh Binaries: Node: 17.9.1 - ~/.nvm/versions/node/v17.9.1/bin/node npm: 8.11.0 - ~/.nvm/versions/node/v17.9.1/bin/npm Watchman: 2022.10.24.00 - /opt/homebrew/bin/watchman Browsers: Brave Browser: 106.1.44.101 Chrome: 107.0.5304.87 Firefox: 105.0.3 Safari: 15.6.1 npmGlobalPackages: corepack: 0.10.0 npm: 8.11.0 ```

Describe the bug

isValid() from CognitoUserSession produces inconsistent results as it is sometimes undefined.

Error produced: TypeError: undefined is not a function

Expected behavior

CognitoUserSession.isValid() should produce true until user session is expired

Reproduction steps

  1. Successful signUp/ SignIn using AWS Cognito
  2. Store the returned CognitoUserSession
  3. Call CognitoUserSession.isValid() This should work
  4. Immediately close the app and open it back up
  5. Make sure CognitoUserSession is not undefined It shouldn't be
  6. call CognitoUserSession.isValid() This produces an error

Code Snippet

// Put your code below this line.
import React from 'react';
import {NavigationContainer} from '@react-navigation/native';

import {AppStack} from './AppStack';
import {AuthStack} from './AuthStack';
import {useAuth} from '../contexts/ReliefAuth';
import {Loading} from '../components/Loading';

export const Router = () => {
  const {authData, authSession,loading} = useAuth();

  if (loading) {
    return <Loading />;
  }

  const isSignedIn = ():boolean => {
    if(authSession === undefined) {
      return false;
    }

    if(authSession.isValid()) {
      console.log("authSession: ",authSession);
      return true;
    }
    return false;
  }
  return (
    <NavigationContainer>
      { isSignedIn() ? <AppStack /> : <AuthStack />}
    </NavigationContainer>
  );
};

Log output

``` // Put your logs below this line TypeError: undefined is not a function This error is located at: in Router (created by App) in AuthProvider (created by App) in App in RCTView (created by View) in View (created by AppContainer) in RCTView (created by View) in View (created by AppContainer) in AppContainer in Relief(RootComponent), js engine: hermes ```

aws-exports.js

No response

Manual configuration

No response

Additional configuration

No response

Mobile Device

Android Emulator: Pixel 6

Mobile Operating System

API 31

Mobile Browser

No response

Mobile Browser Version

No response

Additional information and screenshots

Screen Shot 2022-11-02 at 2 45 49 PM
tannerabread commented 2 years ago

Hi @iambryanwho :wave:

Are you using Amplify here? If so and you are implementing it in '../contexts/ReliefAuth' or ./AuthStack, that would be helpful to see.

Also if you could run the command to get the environment info in the root of your project or provide your package.json that would be helpful as well.

iambryanwho commented 2 years ago

Sure no problem @tannerabread . Here is RelifAuth.tsx as seen below (I removed some calls not being used in this example)


type AuthContextData = {
    authData?: CognitoUser;
    authSession?: CognitoUserSession;
    loading: boolean;
    signUp(registerState: RegisterUser): Promise<void>;
    resendConfirmationCode(): Promise<void>;
    confirmSignIn(code: string): Promise<void>;
    signIn(phoneNumber: string): Promise<void>;
    signOut(): void;
    globalSignOut(): void;
};

Auth.configure({
  authenticationFlowType: 'CUSTOM_AUTH'
});

//Create the Auth Context with the data type specified
//and a empty object
const AuthContext = createContext<AuthContextData>({} as AuthContextData);

const AuthProvider: React.FC<ReliefProps> = ({children}) => {
  const [authData, setAuthData] = useState<CognitoUser>();
  const [authSession, setAuthSession] = useState<CognitoUserSession>();

  //the AuthContext start with loading equals true
  //and stay like this, until the data be load from Async Storage
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    //Every time the App is opened, this provider is rendered
    //and call de loadStorage function.
    loadStorageData();

    //attach Amplify listener for authentication events
    Hub.listen('auth', listener);
    console.log("hub attached");

    return () => {
      Hub.remove('auth', listener);
      console.log("hub removed");
    }
  }, []);

  async function loadStorageData(): Promise<void> {
    setLoading(true);
    console.log("LOADING STARTED");
    try {
      //Try get the data from Async Storage
      const authDataSerialized = await AsyncStorage.getItem('@AuthData');
      const authSessionSerialized = await AsyncStorage.getItem('@AuthSession');

      if (authDataSerialized) {
        //If there are data, it's converted to an Object and the state is updated.
        const _authData: CognitoUser = await JSON.parse(authDataSerialized);
        setAuthData(_authData);
      }

      if (authSessionSerialized) {
        //If there are data, it's converted to an Object and the state is updated.
        const _authSession: CognitoUserSession = await JSON.parse(authSessionSerialized);
        setAuthSession(_authSession);
      }

    } catch (error) {
    } finally {
      //loading finished
      console.log("LOADING FINISHED");
      setLoading(false);
    }
  }

const confirmSignIn = async (code: string) => {

    if(!authData) {
      console.log("confirmSignIn: authData empty"); //TODO throw error
      return;
    }

    try {

      authData.sendCustomChallengeAnswer(code, {
        async onSuccess(session) {
          session.isValid()
          setAuthSession(session);
          await AsyncStorage.setItem('@AuthSession', JSON.stringify(session));

          console.log("confirmed user signIn");
        },
        onFailure(err) {
          console.log('error confirming sign up', err); //TODO Handle
        },
      })

    } catch (error) {
      console.log('error confirming sign up', error);
    }
  }

  const signIn = async (phoneNumber: string) => {

    try {
        const user: CognitoUser = await Auth.signIn(phoneNumber);

        setAuthData(user);
        await AsyncStorage.setItem('@AuthData', JSON.stringify(user));
        console.log("signed in user");

    } catch (error) {
        console.log('error signing in', error);
    }
  };

return (
    //This component will be used to encapsulate the whole App,
    //so all components will have access to the Context
    <AuthContext.Provider value={{authData, authSession,loading, signUp, resendConfirmationCode, confirmSignIn ,signIn, signOut, globalSignOut}}>
      {children}
    </AuthContext.Provider>
  );
};

//A simple hooks to facilitate the access to the AuthContext
// and permit components to subscribe to AuthContext updates
function useAuth(): AuthContextData {
  const context = useContext(AuthContext);

  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }

  return context;
}

export {AuthContext, AuthProvider, useAuth};
iambryanwho commented 2 years ago

and here are the Environment Variables:


    OS: macOS 12.6
    CPU: (8) x64 Apple M2
    Memory: 18.18 MB / 16.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 17.9.1 - ~/.nvm/versions/node/v17.9.1/bin/node
    npm: 8.11.0 - ~/.nvm/versions/node/v17.9.1/bin/npm
    Watchman: 2022.10.24.00 - /opt/homebrew/bin/watchman
  Browsers:
    Brave Browser: 106.1.44.101
    Chrome: 107.0.5304.87
    Firefox: 105.0.3
    Safari: 15.6.1
  npmPackages:
    @babel/core: ^7.12.9 => 7.19.6
    @babel/runtime: ^7.12.5 => 7.20.0
    @react-native-async-storage/async-storage: ^1.17.10 => 1.17.10
    @react-native-community/art: ^1.2.0 => 1.2.0
    @react-native-community/eslint-config: ^2.0.0 => 2.0.0
    @react-native-community/netinfo: ^9.3.3 => 9.3.6
    @react-native-picker/picker: ^2.4.6 => 2.4.8
    @react-navigation/material-bottom-tabs: ^6.2.4 => 6.2.4
    @react-navigation/native: ^6.0.13 => 6.0.13
    @react-navigation/native-stack: ^6.9.0 => 6.9.1
    @testing-library/react-native: ^11.2.0 => 11.3.0
    @tsconfig/react-native: ^2.0.2 => 2.0.2
    @types/jest: ^26.0.23 => 26.0.24
    @types/react-native: ^0.70.0 => 0.70.6
    @types/react-test-renderer: ^18.0.0 => 18.0.0
    @typescript-eslint/eslint-plugin: ^5.37.0 => 5.41.0 (3.10.1)
    @typescript-eslint/parser: ^5.37.0 => 5.41.0 (3.10.1)
    HelloWorld:  0.0.1
    amazon-cognito-identity-js: ^5.2.10 => 5.2.12
    aws-amplify: ^4.3.37 => 4.3.42
    aws-amplify-react-native: ^6.0.5 => 6.0.8
    babel-jest: ^26.6.3 => 26.6.3 (29.2.2)
    eslint: ^7.32.0 => 7.32.0
    example:  0.0.1
    hermes-inspector-msggen:  1.0.0
    jest: ^29.2.0 => 29.2.2
    metro-react-native-babel-preset: ^0.72.1 => 0.72.3 (0.72.1)
    react: 18.1.0 => 18.1.0
    react-native: 0.70.1 => 0.70.1
    react-native-paper: ^4.12.5 => 4.12.5
    react-native-pie-chart: ^2.0.2 => 2.0.2
    react-native-safe-area-context: ^4.4.1 => 4.4.1
    react-native-screens: ^3.17.0 => 3.18.2
    react-native-vector-icons: ^9.2.0 => 9.2.0
    react-test-renderer: 18.1.0 => 18.1.0
    typescript: ^4.8.3 => 4.8.4
  npmGlobalPackages:
    corepack: 0.10.0
    npm: 8.11.0
tannerabread commented 2 years ago

It may or may not make a difference but you shouldn't need aws-amplify-react-native in your package.json to use amplify with React Native.

Outside of that, am I interpreting this correctly and you are rebuilding the entire Auth flow yourself?

From what I see: Auth.configure call only has authenticationFlowType: 'CUSTOM_AUTH' in it, are you configuring the rest of your resources elsewhere?

Are you storing the user data manually in your AsyncStorage? I can't see your method to store the data I see how you are retrieving it

iambryanwho commented 2 years ago

We're using a custom auth flow that creates accounts using a phoneNumber. The remaining configurations are in my App.tsx which pulls configurations from my auto-generated aws-exports.js file:

App.tsx

import {
  Amplify,
  Auth
} from 'aws-amplify'
import awsconfig from './src/aws-exports'
Amplify.configure({
  Auth: {
    region: awsconfig.aws_appsync_region,
    userPoolId: awsconfig.aws_user_pools_id,
    userPoolWebClientId: awsconfig.aws_user_pools_web_client_id,
  }
});

User data is being stored in ReliefAuth.tsx usning AsyncStorage:

ReliefAuth.tsx

import AsyncStorage from '@react-native-async-storage/async-storage';
...

const AuthProvider: React.FC<ReliefProps> = ({children}) => {

//storing in state
  const [authData, setAuthData] = useState<CognitoUser>();
  const [authSession, setAuthSession] = useState<CognitoUserSession>();
...

const signUp = async (registerState: RegisterUser) => {  
    const phone_number = parseInt(registerState.phoneNumber.substring(1)) as number;

    const credentials: SignUpCredentials = {
        username: registerState.phoneNumber,
        password: "TODO use uuid4() to generate password", 
        autoSignIn: {
            enabled: false, 
          },
    }

    try {
        const result: ISignUpResult  = await Auth.signUp(credentials);
        const user  = result.user;

        //SAVING to state and to storage
        setAuthData(user);
        await AsyncStorage.setItem('@AuthData', JSON.stringify(user));

    } catch (error) {
        console.log('error signing up:', error);
    }
  }
...

You can see the use of AsynStorage in the code snippets earlier in the signIn() and confirmSignIn()

More than happy to do a walkthrough/screenshare as well @tannerabread

tannerabread commented 2 years ago

@iambryanwho sorry for the delayed response.

Before we go through a walkthrough/screenshare, I wanted to make a few observations and ask a few questions.

It is generally recommended to configure the entire app in the same location and at the root of the project. There's a chance of having multiple instances of your Auth object when configuring in multiple places. For a React Native built from the CLI (not-Expo), normally Amplify is configured in index.js. When aws-exports is present from using the Amplify CLI, the app can be configured as follows:

import { Amplify } from 'aws-amplify';
import awsconfig from './src/aws-exports';

Amplify.configure({
    ...awsconfig,
    authenticationFlowType: 'CUSTOM_AUTH'
})

Another question about the config and the custom auth flow, is the custom auth flow only to create accounts with phone numbers? That is possible OOTB and the custom auth would only be needed to administer custom challenges. I see a call to sendCustomChallengeAnswer in the confirmSignIn method but it is through the CognitoUser object instead of through Amplify. If it is being used elsewhere, that's fine but I would recommend use the Auth.sendCustomChallengeAnswer(user challengeResponse) flow instead of calling the Cognito method directly.

I see the use of AsyncStorage in the app, and am wondering why this is being done manually instead of letting Amplify handle the storage/retrieval of the user/session? The flow described in the docs would do that step automatically. I don't see anything very unusual with the signIn method other than storing the session manually. It's possible when this is done, there are two versions of the session and user in AsyncStorage (one set by you and one set by Amplify), of which 1 might be undefined if something went wrong.

What is the reason for using the Context/Provider flow to encapsulate the whole app and passing methods from Amplify to it? Instead, it would normally be recommended to just sign in with Amplify's usual flow and then call methods from Auth when needed. If this were done, Auth.currentAuthenticatedUser could be used on the <Router> component to check if the user is signed in and then the content could be rendered conditionally from there.

iambryanwho commented 2 years ago

Thanks @tannerabread . Yes, Im currently using the custom auth flow to create accounts just with phone numbers. But the idea was to use custom to have more future flexibility for changing settings such as length of the custom code sent, etc. If the OOTB solution allows equal flexibility as custom then I can switch to OOTB. Is that the case?

Also does Amplify handle storage/retrieval even in a custom auth flow?

tannerabread commented 2 years ago

@iambryanwho

If the OOTB solution allows equal flexibility

Sorry, that is not the case. I was mistaken and it is not possible to do passwordless authentication OOTB. You would need to keep going the custom_auth route for that but I would still recommend using the methods from the Auth package instead of Cognito directly.

The storage/retrieval part should be automatic though whether you have a normal or custom auth flow. From my understanding if you are calling Amplify Auth methods, it will store/retrieve them without any extra setup.

That all being said, I'm still curious if either that or the configuration being in multiple places was the cause of your issue. Were you able to change the config to the root of the project?

iambryanwho commented 2 years ago

@tannerabread using Auth works. Thanks

tannerabread commented 2 years ago

Closing this as resolved. If you experience any other issues with Amplify please feel free to open a new issue so that we can assist you. Also feel free to join our discord server to speak with the community of Amplify developers.

Thank you!