okta-react-native icon indicating copy to clipboard operation
okta-react-native copied to clipboard

[iOS] storedStateManager is randomly not set, which creates sdk authenticated methods rejection

Open Aeners opened this issue 1 year ago • 3 comments

Describe the bug?

I previously posted an issue #403 which relate the same behaviour, but I think that I understand the issue more in depths, so I prefered filling up a new issue.

Hello, the issue we encounter occurs on iOS devices only. After some time, our users get "random" disconnections, they can use the app for a few days without any issue and suddenly they are logged out.

This seems to be due to okta-react-native sdk where getAccessToken and refreshTokens methods are throwing an unexpected error. The message thrown inside the error is:

User is not authenticated, cannot perform the specific action

I took a look at the sdk iOS native code, which lead me to OktaSdkBridge.swift file. In both getAccessToken and refreshTokens function declaration, I found out that that error is thrown when the storedStateManager is not set.

So if my digging is right, what could cause the storedStateManager to be unset?

Note that when this occurs, the token is still valid! I've been able to use the access token outside of the SDK scope and retrieve a successful response.

What is expected to happen?

Understand why the SDK methods getAccessToken and refreshTokens throw an error. Could we have a wrong configuration of the SDK so the storedStateManager gets unset/reset?

What is the actual behavior?

Methods that use the storedStateManager variable are throwing an unexpected error "randomly".

User is not authenticated, cannot perform the specific action

Reproduction Steps?

We created a minimal project in order to narrow down as much as possible the issue. It is a bit tricky to give you access to it so I did my best below to describe the key points this project uses.

App initialisation

During the first render of the App, we create the okta config using the createConfig method. This lands in a useEffect hook at the entrypoint of our App.

const oktaConfig: OktaConfig = {
  clientId: CLIENT_ID,
  redirectUri: REDIRECT_URI,
  endSessionRedirectUri: END_SESSION_REDIRECT_URI,
  discoveryUri: DISCOVERY_URI,
  scopes: ['openid', 'profile', 'offline_access'],
  requireHardwareBackedKeyStore: false,
};

const App = () => {
  const [appIsReady, setAppIsReady] = useState(false);

  useEffect(() => {
    createConfig(oktaConfig).finally(() => {
      setAppIsReady(true);
    });
  }, []);

  return (
    <FantasticWrapper>
     {appIsReady ? <GreatAppNavigation /> : <LoadingScreen />}
    </FantasticWrapper>
  );
}

Sign in screen

This screen is a simple authentication form, asking for user credentials. On submit the app is calling the signIn method with crendentials filled in:

signIn({ username: email, password });

Using tokens for requests

From now on, we make requests through a GraphQL client. Right before sending a request, the graphQL client calls getAccessToken method, and pass along the access token with the request. In case the token expires, our BE responds with a 401 Unauthorized error, which triggers an error link of our graphQL client. In this error link, we call the refreshTokens method, and then retry the request passing along the newly refreshed access token in it. Now in case, the refreshTokens method throws an error, we force the disconnection of the user.

const refreshAndReturnAccessToken = async () => {
  try {
    const refreshTokensResponse = await refreshTokens();
    return refreshTokensResponse.access_token;
  } catch {
    // Shutting down session if unable to refresh tokens
    forceAppLogOut();
    return null;
  }
};

export const attachTokenLink = (() =>
  setContext(async (_, headers) => {
    let accessToken = '';

    try {
      const getAccessTokenResponse = await getAccessToken();
      accessToken = getAccessTokenResponse.access_token;
    } catch {
      accessToken = await refreshAndReturnAccessToken();
    }

    return {
      headers: {
        ...headers,
        authorization: `Bearer ${accessToken}`,
      },
    };
  }))();

export const errorLink = onError(({ graphQLErrors, operation, forward }) => {
  if (graphQLErrors) {
    for (let error of graphQLErrors) {
      if (error.extensions?.status === 401) {
        return fromPromise(
          refreshAndReturnAccessToken().then(token => {
            if (!token) {
              return null;
            }
            const previousHeaders = operation.getContext().headers;
            operation.setContext({
              headers: {
                ...previousHeaders,
                authorization: `Bearer ${tokens.access_token}`,
              },
            });
            return tokens.access_token;
          }))
          .filter(Boolean)
          .flatMap(() => forward(operation));
        }
      }
    }
});

export const client = new ApolloClient({
  link: from([errorLink, attachTokenLink, httpLink]),
});

getAccessToken and refreshTokens throw unexpected errors randomly

After being logged in, after some time the getAccessToken and the refreshTokens method throws an error with this message:

User is not authenticated, cannot perform the specific action

Additional Information?

Here is the previous issue #403 I posted, at that time we were more wondering if the token was wrongly compared by the device. Though the discussion might help so I leave the link of it.

SDK Version

2.12.0

Build Information

No response

Aeners avatar Apr 09 '24 15:04 Aeners

Seeing the same problem in our implementation. Has there been any progress here?

leandigital avatar Nov 05 '24 16:11 leandigital

Hi @Aeners, do you still have the issue? We started experimenting a similar-ish issue where many iOS devices restart our application simultaneously. We also see a spike in User is not authenticated, cannot perform the specific action from getAccessToken calls, and we were wondering the following if you don't mind:

1- Is this happening on individual devices or multiple devices at once for you? 2- Did you find what could cause the storedStateManager to unset? 3- Did you find a fix for this?

ThomasTrepanier avatar Mar 27 '25 19:03 ThomasTrepanier

Hi @ThomasTrepanier, I'm no longer part of this project and we "solved" that a while ago now, so note that my answer might be blurry and I would suggest taking time to investigate yourself.

That being said, what we discovered is that the Okta SDK on iOS seemed to have trouble accessing the internal phone storage while the app was not in foreground. The problem was that all the sensitive token data, were stored there. So the SDK started to throw this "generic" error User is not authenticated, cannot perform the specific action as there wasn't a specific catch for that clause.

We couldn't figure out how to solve this, but we saw that only a very small amount of these calls to internal storage led to this error. More over, we removed the "force disconnection" logic, and the errors were thrown successively until for whatever reason it started to work again...

As we were clueless on why, we decided to filter the errors coming from the SDK, to only disconnect the user when the error thrown mentions something like refresh token is expired.

And this did the trick, as the "black out" from the SDK was temporary and localised in time (never more than a few seconds), no one never raised an issue linked to that. So we agreed that the app might be unable to be used very rarely for a few seconds, and not to spend more time on this issue. At least not until I left the team.


So to go over your questions:

  1. We never measured a collection of errors happening at the same time on multiple devices, so it didn't look like the issue was cross devices.
  2. No, we never got the real reason why this was happening. A few months after the post, we hired a senior native engineer in our team, with a strong background in Swift. And he gave it shot to understand the issue, but as we never really understood how to reproduce this, it was really time consuming and he put it aside.
  3. As I mentioned above, we simply filtered the error thrown to stop disconnect the user on that embarrassing error.

Aeners avatar Apr 02 '25 10:04 Aeners