Retaining Authentication after App Update

I have an Angular/Ionic/Capacitor app that uses the ionic-appauth library to handle it’s okta authentication. That library also uses the capacitor secure-storage plugin to store the okta access token locally.

I’m wanting to replace this app with an entirely new app (but via an App update through the store rather than a new app install). I’d like users to remain authenticated when they update the app so they do not have to remember their credentials and sign in when the app updates.

The updated app is a React / Capacitor app which uses the okta-auth-js library. I have added the capacitor-secure-storage plugin to this app just to get the token on startup.

To test the above 'update and stay authenticated" task out I have a button displayed on screen which makes a test call. When the app opens up I retrieve the access token from storage and make the test call - the call works fine. It continues to work ok until this token expires (1 hour) and although in my code I try and refresh the token at this point, the call still fails (401) and the login screen appears.

Asking CoPilot for help I added a migrateToken method in case the issue was coming from an app that uses the ionic-appauth library which uses @openid/appauth to my updated version which uses okyta-auth-js. But adding this method and called it on startup has not had any affect.

Is what I am trying to do possible? (I would have thought it would be)

Is there anyything I am missing around tokens and refreshing that I need to do in the updated app?

I have checked the okta config and it is in the same in both apps i.e the same issuer, clientId, redirectUri, scopes and pkce setting.

After 1 hour when I try the call I see the following console messages:

[log] - Access token expired. Attempting to refresh.
⚡️  [error] - Failed to refresh token: {"name":"AuthSdkError","errorCode":"INTERNAL","errorSummary":"renewTokens: invalid tokens: could not read authorizeUrl","errorLink":"INTERNAL","errorId":"INTERNAL","errorCauses":[],"tokenKey":"accessToken"}
⚡️  [log] - Redirecting to login...

Here is my test code in App.jsx of my updated app:

import React, { useEffect } from 'react';

import { SplashScreen } from '@capacitor/splash-screen';
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
import fetcher from 'services/fetcher';
import { OktaAuth } from '@okta/okta-auth-js';

const oktaAuth = new OktaAuth({
  issuer: 'https://mydomain/oauth2/default',
  clientId: 'xxxxxxxxxxxxxxxxxxxxxx',
  redirectUri: 'mydomain:/callback',
  scopes: ['openid', 'offline_access'],
  pkce: true,
});

const App = () => {
  const migrateTokens = async () => {
    const { value: oldTokenResponse } = await SecureStoragePlugin.get({
      key: 'token_response',
    });

    if (oldTokenResponse) {
      const parsedTokenResponse = JSON.parse(oldTokenResponse);
      const accessToken = parsedTokenResponse?.access_token;
      const refreshToken = parsedTokenResponse?.refresh_token;

      if (accessToken && refreshToken) {
        const tokenToStore = {
          accessToken: accessToken,
          refreshToken: refreshToken,
          expiresAt: Math.floor(Date.now() / 1000) + 3600, // Assume 1-hour expiry
          scopes: ['openid', 'profile', 'offline_access'],
        };

        oktaAuth.tokenManager.add('accessToken', tokenToStore);
        console.log('Tokens migrated successfully.');
      }
    }
  };

  const initializeAuth = async () => {
    try {
      // Retrieve the token from Secure Storage
      const { value: tokenResponse } = await SecureStoragePlugin.get({
        key: 'token_response',
      });

      if (!tokenResponse) {
        console.log('No token found in secure storage.');
        oktaAuth.signInWithRedirect(); // Redirect to login if no token is found
        return;
      }

      // Parse the token
      const parsedTokenResponse = JSON.parse(tokenResponse);
      const accessToken = parsedTokenResponse?.access_token;
      const refreshToken = parsedTokenResponse?.refresh_token;

      if (!accessToken) {
        console.log('Access token not found in secure storage.');
        oktaAuth.signInWithRedirect(); // Redirect to login if no access token is found
        return;
      }

      // Decode the token to check expiration
      const tokenPayload = JSON.parse(atob(accessToken.split('.')[1])); // Decode the JWT payload
      const currentTime = Math.floor(Date.now() / 1000); // Current time in seconds

      if (tokenPayload.exp && tokenPayload.exp > currentTime) {
        console.log(
          'Token is valid based on expiration time. Storing in token manager.',
        );

        // Transform the token into the correct format
        const tokenToStore = {
          accessToken: accessToken,
          refreshToken: refreshToken, 
          expiresAt: tokenPayload.exp, 
          scopes: ['openid', 'offline_access'],
          pkce: true,
        };

        // Add the token to the token manager
        oktaAuth.tokenManager.add('accessToken', tokenToStore);
      } else if (refreshToken) {
        console.log('Token expired. Attempting to refresh.');
        try {
          console.log('Refresh Token:', refreshToken); // Log the refresh token
          const refreshedTokens = await oktaAuth.token.refresh(refreshToken);
          oktaAuth.tokenManager.setTokens(refreshedTokens);
          console.log('Token refreshed successfully.');
        } catch (refreshError) {
          console.error('Failed to refresh token:', refreshError);
          console.log('Redirecting to login...');
          oktaAuth.signInWithRedirect(); // Redirect to login if refresh fails
        }
      } else {
        console.log('Token expired and no refresh token available.');
        oktaAuth.signInWithRedirect(); // Redirect to login if no valid token is available
      }
    } catch (error) {
      console.error('Error initializing authentication:', error);
      oktaAuth.signInWithRedirect(); // Redirect to login if something goes wrong
    }
  };

  const performTestCall = async () => {
    try {
      // Clear any saved auth transactions
      oktaAuth.transactionManager.clear();

      // Retrieve the access token from the token manager
      let accessToken = await oktaAuth.tokenManager.get('accessToken');

      if (!accessToken) {
        console.log('No access token available. Redirecting to login.');
        oktaAuth.signInWithRedirect();
        return;
      }

      // Check if the token is expired
      const currentTime = Math.floor(Date.now() / 1000); // Current time in seconds
      if (accessToken.expiresAt && accessToken.expiresAt <= currentTime) {
        console.log('Access token expired. Attempting to refresh.');
        try {
          oktaAuth.transactionManager.clear();
          const refreshedTokens =
            await oktaAuth.tokenManager.renew('accessToken');
          accessToken = refreshedTokens.accessToken;
          console.log('Token refreshed successfully.');
        } catch (refreshError) {
          console.error('Failed to refresh token:', refreshError);
          console.log('Redirecting to login...');
          oktaAuth.signInWithRedirect(); // Redirect to login if refresh fails
          return;
        }
      }

      // Make the API call with the valid access token
      const route =
        'https://xxx.co.uk/testCall';
      const params = {
        device: 'ion-ios/5.2.6',
      };

      const config = {
        headers: {
          Authorization: `Okta ${accessToken.accessToken}`,
          'X-Requested-App': 'feed',
          'X-Requested-App-User': '100000160',
          'X-Requested-Type': 'ionic',
          'Cache-Control': 'no-cache',
        },
        withCredentials: false,
      };

      console.log('Performing test call...');
      const data = await fetcher(route, params, config);
      console.log('Test call successful:', data);
      alert(`Test call successful:\n${JSON.stringify(data, null, 2)}`);
    } catch (error) {
      console.error('Test call failed:', error);
      alert(`Test call failed: ${error.message}`);
    }
  };

  useEffect(() => {
    const handleRedirectCallback = async () => {
      try {
        const tokens = await oktaAuth.token.parseFromUrl();
        oktaAuth.tokenManager.setTokens(tokens);
      } catch (error) {
        console.error('Error handling redirect callback:', error);
      }
    };

    const initializeApp = async () => {
      await migrateTokens();
      await initializeAuth();
    };

    console.log('App initialized. Checking for redirect callback...');
    console.log('Current URL:', window.location.href);
    if (window.location.pathname === '/callback') {
      handleRedirectCallback();
    } else {
      initializeApp();
    }

    SplashScreen.hide();
  }, []);

  return (
    <div style={styles.container}>
      <button style={styles.button} onClick={performTestCall}>
        Perform Test Call
      </button>
    </div>
  );
};

const styles = {
  container: {
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
    height: '100vh', // Full viewport height
    backgroundColor: '#f5f5f5', // Optional background color
  },
  button: {
    padding: '10px 20px',
    fontSize: '16px',
    backgroundColor: '#007bff',
    color: '#fff',
    border: 'none',
    borderRadius: '5px',
    cursor: 'pointer',
  },
};

export default App;

Hello,

I am not that familiar with Ionic/Capacitor, but looking at the migrateTokens() it seems only the accessToken is stored in the auth-js TokenManager.

Refreshing tokens relies on the refreshToken, not accessToken.

The auth-js doc for TokenManager.add() only states access or id token, but looking at the code it seems like you should be able to add a refreshToken as well
/lib/oidc/TokenManager.ts
/lib/oidc/util/validateToken.ts

Can you try updating migratetokens() to include adding the refreshToken?

Hi @erik thanks for the reply. Yes, my code has moved on a bit since I posted the question - I store both tokens now but have still had no luck. I’m wondering if there is some fundamental issue going from the @openid/appauth generated tokens to the okta-auth-js refreshing? Or maybe something to do with the app being a capacitor app.

I am not sure what @openid/appauth format uses, but it won’t be the same as okta-auth-js when storing tokens.
okta-auth-js stores an stringify object for each token, not just the token value.

The format can be seen in a browsers local storage (by default) under okta-token-storage.
It probably wouldn’t be to hard to construct all the needed values manually and then store it.

This isn’t something Okta tests, but in theory it should work.