Badisi / auth-js

🛡️ Authentication and authorization support for web based desktop and mobile applications
GNU General Public License v3.0
9 stars 2 forks source link

Need help for implementing / migration in a vue / quasar application #47

Open Excel1 opened 3 days ago

Excel1 commented 3 days ago

Introduction

Currently, there is very little documentation available on using or migrating to badisi auth-js, which makes it challenging to customize my code. For this reason, I am seeking help here. If the implementation is successful, I plan to extend the existing documentation to make the library more accessible.

Current Setup

My project is built with Quasar and Vue 3. I have developed a hybrid application powered by Capacitor, using Keycloak in combination with oidc-client.ts for authentication. While everything works as expected, Apple’s App Store Guideline 4.0 requires using the Safari In-App Browser (via the Capacitor Browser Plugin) for redirection handling instead of the default browser.

Project Details

Migration Goal

The aim is to adapt the authentication flow to comply with Apple's requirements by switching to badisi auth-js while maintaining the existing functionality and platform-specific quirks.

Current Code Base

auth.service.ts

let userManager: UserManager | null = null;
let currentUser: User | null | undefined = undefined;
let refreshTokenInterval: string | number | NodeJS.Timeout | undefined;
let lastLoginTime: string | null = localStorage.getItem('lastLoginTime');
const userStore = useUserStore();
const teamStore = useTeamStore();

export default {
  async initOidcClient(isRedirect?: boolean) {
    userManager = getUserManagerInstance();
    try {
      if (isRedirect) {
        if (AppService.isMobile()) {
          if (AppService.isAndroid()) {
            currentUser = await userManager.signinRedirectCallback(window.location.href.replace('/#/', '/'));
          } else {
            currentUser = await userManager.signinRedirectCallback(window.location.href.replace('#/', '/'));
          }
        } else {
          currentUser = await userManager.signinRedirectCallback();
        }
      } else {
        currentUser = await userManager.getUser();
      }

      if (currentUser && !currentUser.expired) {
        setTokenInterval();
        registerTokenInterceptor();
        return currentUser;
      }

      setTokenInterval();
      registerTokenInterceptor();

      return currentUser;
    } catch (error) {
      throw error;
    }
  },
  async login(redirectUri?: string) {
    try {
      await this.initOidcClient(false);
      await userManager?.signinRedirect({ redirect_uri: redirectUri });
    } catch (error) {
      throw error;
    }
  },
  async logout() {
    await logoutDeviceSpecific();
  },
  async onResume() {
    if (currentUser && currentUser.expired) {
      await refreshAccessToken();
    }
  }
};

async function logoutDeviceSpecific() {
  clearInterval(refreshTokenInterval);
  try {
    if (Platform.is.mobile) {
      userStore.clearCurrentUser();
      teamStore.clearCurrentTeam();
      if (AppService.isAndroid()) {
        await userManager?.signoutSilent();
      } else {
        await userManager?.signoutRedirect({post_logout_redirect_uri: 'myapp://logout'})
      }
    } else {
      const cookiesValue = localStorage.getItem('cookies');

      localStorage.clear();

      if (cookiesValue !== null) {
        localStorage.setItem('cookies', cookiesValue);
      }

      await userManager?.signoutRedirect();
    }
  } catch (error) {
    console.error('OIDC logout error:', error);
  }
}

function getUserManagerInstance() {
  if (!userManager) {
    if (AppService.isMobile()) {
      userManager = new UserManager({
        authority: 'keycloakurl',
        client_id: 'client',
        redirect_uri: 'myapp://login',
        post_logout_redirect_uri: 'myapp:/' + '/logout',
        response_type: 'code',
        scope: 'openid profile email offline_access',
        filterProtocolClaims: true,
        loadUserInfo: true,
        automaticSilentRenew: false,
        userStore: new WebStorageStateStore({ store: new MobileStorage() })
      });
    } else {
      userManager = new UserManager({
        authority: 'keycloakurl',
        client_id: 'client',
        redirect_uri: window.location.origin + '/login',
        post_logout_redirect_uri: window.location.origin,
        response_type: 'code',
        scope: 'openid profile email offline_access',
        filterProtocolClaims: true,
        loadUserInfo: true,
        automaticSilentRenew: false,
        userStore: new WebStorageStateStore({ store: window.localStorage })
      });
    }
  }
  return userManager;
}

function registerTokenInterceptor() {
  api.interceptors.request.use(async (config) => {

    const status = await Network.getStatus();
    console.log('Network status:', status.connected);
    if (!status.connected) {
      return Promise.reject(new axios.Cancel('No internet connection'));
    }

    console.log('Request interceptor:', config);

    let user = await userManager?.getUser();
    if (user && !user.expired) {
      config.headers.Authorization = `Bearer ${user.access_token}`;
    }
    if (user?.expired) {
      user = await userManager?.signinSilent();
      config.headers.Authorization = `Bearer ${user?.access_token}`;
    }
    return config;
  });
}

function setTokenInterval() {
  refreshTokenInterval = setInterval(refreshAccessToken, 1000000);
}

async function refreshAccessToken() {
  if (currentUser) {
    try {
      currentUser = await userManager?.signinSilent();
      if (currentUser && !currentUser.expired) {
        console.log('Access token refreshed');
        lastLoginTime = String(Date.now());
      } else {
        console.log('Access token refresh failed');
      }
    } catch (error) {
      console.error('Error refreshing access token:', error);
      if (error instanceof Error && error.message === 'Stale token') {
        console.log('Stale token, signing out');
        await logoutDeviceSpecific();
      }

      // 28 days
      if (lastLoginTime && Date.now() - Number(lastLoginTime) > 2419200000) {
        console.log('Token expired, signing out');
        await logoutDeviceSpecific();
      }
    }

    if (lastLoginTime && Date.now() - Number(lastLoginTime) > 2419200000) {
      console.log('Token expired, signing out');
      await logoutDeviceSpecific();
    }
  }
}

authentication.ts - boot

export default boot(async ({ router }) => {
  const authStore = useAuthStore();

  try {
    await authStore.initOidcClient(isRedirect);
  } catch (error) {
    console.error('Failed to initialize OIDC client:', error);
    console.log(authStore.getUser);
  }
});

auth.store.ts

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: <User | null | undefined>undefined,
  }),
  getters: {
    getUser(): User | null | undefined {
      return this.user;
    },
    isAuthenticated(): boolean | undefined {
      return !!this.user && !this.user.expired;
    },
    isOfflineAuthenticated(): boolean | undefined {
      return !!this.user?.refresh_token;
    },
    getEmail(): string | undefined {
      return this.user?.profile?.preferred_username;
    },
  },
  actions: {
    async initOidcClient(isRedirect?: boolean) {
      try {
        this.user = await AuthService.initOidcClient(isRedirect);
      } catch (error) {
        console.log('Failed to initialize OIDC client:', error);
      }
    },
    async login(redirectUri?: string) {
      try {
        await AuthService.login(redirectUri);
      } catch (error) {
        throw error;
      }
    },
    async logout() {
      try {
        await AuthService.logout();
      } catch (error) {
        console.error(error);
      }
    }
  }
});

routerGuard.ts - boot

export default boot(({ router }) => {

  async function initializeOidcAfterRouting() {
    console.log('OIDC client initialized');
    try {
      await authStore.initOidcClient(true);
    } catch (error) {
      console.error('Failed to initialize OIDC client:', error);
    }
  }

  const authStore = useAuthStore();

  router.beforeEach(async (to, from, next) => {

    if (authStore.isOfflineAuthenticated && to.fullPath.includes('/login')) {
      next('/');
    }

    if (to.matched.some(record => record.meta?.requiresAuth)) {
      if (authStore.isOfflineAuthenticated) {
        next();
      } else {
        next('/home');
      }
    } else {
      next();
    }
  });
});

Questions

Badisi commented 2 days ago

Hello @Excel1,

Thanks for the interest and sorry about the lack of documentation. This is clearly something I would like to improve, but always find myself not having enough time to.


To give you some highlights:


Regarding the code your provided:

Based on what I see, most of this code could easily be removed as it is already managed by this library.


Regarding Apple's guidelines:

This library will never use the "system browser" as it is not considered a best practice. Right now the library is expecting @capacitor/browser to be installed (which is an "in-app browser"). But I'm also planning to develop another library (later on) which will provide "custom tabs / browser tabs browsers", which are required for SSO to work.


Regarding offline mode:

The library will automatically renew the user's tokens, one minute prior to access_token expiration (this could be overridden by settings). At that time, it will uses the refresh_token to do the renewal. So users can remain authenticated, with their tokens renewed silently, as long as their session is active.


Regarding tokens storage:

On desktop As a good security practice, tokens are not stored on desktop - they are kept in the app memory. So as long as you are logged in and the app is live, when your access token is going to expired, the refresh token will be used to renew the user access. But once the app is closed, the tokens are lost. Reopening the app (or simply refreshing the page), means that the user will not be re-logged in automatically (even if his session is still active). So in case the lib was configured with retrieveUserSession: true -> an hidden iFrame will be used to contact the IDP and retrieve the tokens when the app starts.

On mobile Tokens are stored in the device so that the user could be automatically re-logged in when reopening the app. Depending on the plugins you have installed in your app, the library will recognize and use the following storage (by priority order):

  1. capacitor-secure-storage-plugin (recommended)
  2. @capacitor/preferences
  3. @capacitor/storage
  4. localStorage

Regarding your questions:

  1. Can I use auth-js to implement exactly what I have already implemented? (different redirectUris, offline login)

Anything that was working with oidc-client-ts should work with this library I just have a doubt about your redirect uris point but this could be managed easily too if required

  1. Where do I have to start with the migration and what do I have to consider?

Have a look at: https://github.com/Badisi/auth-js/issues/30#issuecomment-1436074818 And also at the Angular's implementation and the demo apps Live demo app is currently broken for VanillaJS, but you can play with the Angular one: here You can play with the demo apps: here Finally, reach to me if you need more help, as I'm really interested in making this library also available for Vue

  1. How extensive will the migration be and can I keep the current pattern?

Migration should be fairly easy as the idea behind this library is to do everything for you

Excel1 commented 1 day ago

@Badisi At first thank you for the detailed answer. I tried to translate oidc-client.ts code into using your code. Can you take a look if the transformation should working?

Preparations

  1. installed @Badisi/auth-js
  2. capacitor browser plugin
  3. capacitor/preferences

Code Transformation

auth.service.ts

let userManager: UserManager | null = null;
let currentUser: User | null | undefined = undefined;
let refreshTokenInterval: string | number | NodeJS.Timeout | undefined;
let lastLoginTime: string | null = localStorage.getItem('lastLoginTime');
const userStore = useUserStore();
const teamStore = useTeamStore();

export default {
  async initOidcClient(isRedirect?: boolean) {
    userManager = getUserManagerInstance();
    try {
      if (isRedirect) {
        if (AppService.isMobile()) {
          if (AppService.isAndroid()) {
            currentUser = await userManager.signinRedirectCallback(window.location.href.replace('/#/', '/'));
          } else {
            currentUser = await userManager.signinRedirectCallback(window.location.href.replace('#/', '/'));
          }
        } else {
          currentUser = await userManager.signinRedirectCallback();
        }
      } else {
        currentUser = await userManager.getUser();
      }

      if (currentUser && !currentUser.expired) {
        setTokenInterval();
        registerTokenInterceptor();
        return currentUser;
      }

      setTokenInterval();
      registerTokenInterceptor();

      return currentUser;
    } catch (error) {
      throw error;
    }
  },
  async login(redirectUri?: string) {
    try {
      await this.initOidcClient(false);
      await userManager?.signinRedirect({ redirect_uri: redirectUri });
    } catch (error) {
      throw error;
    }
  },
  async logout() {
    await logoutDeviceSpecific();
  },
  async onResume() {
    if (currentUser && currentUser.expired) {
      await refreshAccessToken();
    }
  }
};

async function logoutDeviceSpecific() {
  clearInterval(refreshTokenInterval);
  try {
    if (Platform.is.mobile) {
      userStore.clearCurrentUser();
      teamStore.clearCurrentTeam();
      if (AppService.isAndroid()) {
        await userManager?.signoutSilent();
      } else {
        await userManager?.signoutRedirect({post_logout_redirect_uri: 'myapp://logout'})
      }
    } else {
      const cookiesValue = localStorage.getItem('cookies');

      localStorage.clear();

      if (cookiesValue !== null) {
        localStorage.setItem('cookies', cookiesValue);
      }

      await userManager?.signoutRedirect();
    }
  } catch (error) {
    console.error('OIDC logout error:', error);
  }
}

function getUserManagerInstance() {
  if (!userManager) {
    if (AppService.isMobile()) {
      userManager = new UserManager({
        authority: 'keycloakurl',
        client_id: 'client',
        redirect_uri: 'myapp://login',
        post_logout_redirect_uri: 'myapp:/' + '/logout',
        response_type: 'code',
        scope: 'openid profile email offline_access',
        filterProtocolClaims: true,
        loadUserInfo: true,
        automaticSilentRenew: false,
        userStore: new WebStorageStateStore({ store: new MobileStorage() })
      });
    } else {
      userManager = new UserManager({
        authority: 'keycloakurl',
        client_id: 'client',
        redirect_uri: window.location.origin + '/login',
        post_logout_redirect_uri: window.location.origin,
        response_type: 'code',
        scope: 'openid profile email offline_access',
        filterProtocolClaims: true,
        loadUserInfo: true,
        automaticSilentRenew: false,
        userStore: new WebStorageStateStore({ store: window.localStorage })
      });
    }
  }
  return userManager;
}

function registerTokenInterceptor() {
  api.interceptors.request.use(async (config) => {

    const status = await Network.getStatus();
    console.log('Network status:', status.connected);
    if (!status.connected) {
      return Promise.reject(new axios.Cancel('No internet connection'));
    }

    console.log('Request interceptor:', config);

    let user = await userManager?.getUser();
    if (user && !user.expired) {
      config.headers.Authorization = `Bearer ${user.access_token}`;
    }
    if (user?.expired) {
      user = await userManager?.signinSilent();
      config.headers.Authorization = `Bearer ${user?.access_token}`;
    }
    return config;
  });
}

function setTokenInterval() {
  refreshTokenInterval = setInterval(refreshAccessToken, 1000000);
}

async function refreshAccessToken() {
  if (currentUser) {
    try {
      currentUser = await userManager?.signinSilent();
      if (currentUser && !currentUser.expired) {
        console.log('Access token refreshed');
        lastLoginTime = String(Date.now());
      } else {
        console.log('Access token refresh failed');
      }
    } catch (error) {
      console.error('Error refreshing access token:', error);
      if (error instanceof Error && error.message === 'Stale token') {
        console.log('Stale token, signing out');
        await logoutDeviceSpecific();
      }

      // 28 days
      if (lastLoginTime && Date.now() - Number(lastLoginTime) > 2419200000) {
        console.log('Token expired, signing out');
        await logoutDeviceSpecific();
      }
    }

    if (lastLoginTime && Date.now() - Number(lastLoginTime) > 2419200000) {
      console.log('Token expired, signing out');
      await logoutDeviceSpecific();
    }
  }
}

to

export default {
// init oidc client
async function initAuthJsOIDCClient() {
    await initOidc(<OIDCAuthSettings>{
      authorityUrl: 'myAuthority,
      clientId: 'myClient',
      mobileScheme: 'myApp',
    })
  }
}

Questions

  1. Quasar uses localhost#/ for ios and localhost/#/ for android. How can i set it? currentUser = await userManager.signinRedirectCallback(window.location.href.replace('#/', '/'));

  2. I get the usermanager if existing: userManager = getUserManagerInstance(); How do i achieve this in the lib or does it work automatically?

    1. How do i register the token interceptor?

authentication.ts - boot

Questions

  1. Do i still need it? No right, cause i can use the NRouterGuard for it.

auth.store.ts

Questions

  1. I can use the same store right? If we take a look to get the user
    actions: {
    async initOidcClient(isRedirect?: boolean) {
      try {
        this.user = await AuthService.initOidcClient(isRedirect);
      } catch (error) {
        console.log('Failed to initialize OIDC client:', error);
      }
    },

    How can i do get the user?

routerGuard.ts - boot

export default boot(({ router }) => {

  async function initializeOidcAfterRouting() {
    console.log('OIDC client initialized');
    try {
      await authStore.initOidcClient(true);
    } catch (error) {
      console.error('Failed to initialize OIDC client:', error);
    }
  }

  const authStore = useAuthStore();

  router.beforeEach(async (to, from, next) => {

    if (authStore.isOfflineAuthenticated && to.fullPath.includes('/login')) {
      next('/');
    }

    if (to.matched.some(record => record.meta?.requiresAuth)) {
      if (authStore.isOfflineAuthenticated) {
        next();
      } else {
        next('/home');
      }
    } else {
      next();
    }
  });
});

Questions

  1. In the past i got problems, that my OIDC was not reachable. What happens if the oidc is not available? Does the page still load when I initialise the oidc in routerguard?
  2. Here i just need to use isAuthenticated right?
  3. Is this the right place to init the oidc?

Vue Lib

I am very grateful for your help! If I get it working and understand your library better, I will try to convert it into a Vuelib in a study project.

Badisi commented 1 day ago

@Excel1, thanks for the follow-up.

Hard for me to tell based only on those code snippets... Would it be possible for you to set-up a simple project with quasar, capacitor, vue and my lib ? We could then work on it together more easily. Thanks


auth.service.ts

The idea of this library is to start before anything else (i.e. to start even before the bootstrap of your app). It will act as a guard to prevent your app from loading in case the user is not logged-in. And it will also avoid your app context (i.e. Angular, Vue, etc) to be loaded twice due to redirects in case of authentication. So it should be the first thing to run (ex: in Angular it starts in the main.ts which is basically like the first script tag in your index.html). So I don't think an auth.service.ts is the right place in your case.

Quasar uses localhost#/ for ios and localhost/#/ for android. How can i set it?

Not sure you really needs to do it with my lib. But for info you can override anything that's oidc-client-ts related, with initOidc({ internal: X })

I get the usermanager if existing

You don't have to do anything at all. Just call initOidc() and the lib will managed everything for you.

How do i register the token interceptor?

It's actually only available in the Angular implementation. But I have plan to port it to the VanillaJS one. In your case you would have to keep your current Vue implementation. If you can provide a sample project as I said, I would be able to provide a Vue interceptor.

In the past i got problems, that my OIDC was not reachable. What happens if the oidc is not available? Does the page still load when I initialise the oidc in routerguard?

Like I said before, the lib should be initialized even before your app. So in case your IDP is not reachable, your app won't load at all and you can simply present to the user a fallback page with a login button so he can retry the authentication (I can also provide this scenario in the sample project).

Here i just need to use isAuthenticated right?

Depends on your needs

Is this the right place to init the oidc?

Already answered it :-)

Excel1 commented 1 day ago

@Badisi

Hard for me to tell based only on those code snippets... Would it be possible for you to set-up a simple project with quasar, capacitor, vue and my lib ? We could then work on it together more easily. Thanks

Yes ofc. https://github.com/Excel1/quasar-authjs

I have created a simple Quasar Project with a protected and unprotected page (HomePage). Additionally I added a RouterGuard and the auth.service.ts to abstract this, if it makes sense. I changed a few things and tried to map the login process according to suspicion.

I have tried to follow most of the instructions and orientated myself on the projects, but so far I have not been able to log in because initOidc does not seem to be a function. I hope you can recognise the approach and you can do something with it :) If you would like to explain it, so that I can understand it and possibly write documentation - that would be super helpful!

As already mentioned, the boot files are executed at the beginning (boot/routerGuard). If you need more information or the code is not enough for you, contact me and I will try to adapt the code

Badisi commented 13 hours ago

initOidc does not seem to be a function

Was not an easy one, but this was due to the fact that quasar was not supporting esm yet. Luckily they now have a release candidate that does: https://github.com/quasarframework/quasar/issues/12818 https://github.com/quasarframework/quasar/releases/tag/%40quasar%2Fapp-vite-v2.0.0-rc.1

Badisi commented 13 hours ago

@Excel1, I've already managed to make a working version of your project with login and logout. Only guard and injector remains. Please add me as a contributor to your repo so that I can push my modifications directly to it. Thanks

Excel1 commented 4 hours ago

@Badisi Thank you for the fast help! - I added you as a contributor and will update my main project to the release candidate and try to take over the implementation from the project. You are welcome to copy the entire quasar-authjs and put it into your demo folder if you like!

Badisi commented 3 hours ago

Thanks, I have pushed a pre-version of my modifications. It's still in progress and might not completely work as expected, so please wait for it to be stable. I'm currently having a look a the guard and injector part ;-)

Excel1 commented 2 hours ago

@Badisi 🔝 - feel free to contact me, when you are ready :)