Skip to content

[FEATURE] Support for vue / quasar applications #47

Description

@Excel1

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

  • Boot Files: Executed immediately when the app starts or the webpage loads in a browser.
  • Service & Store Pattern: The app follows this pattern for state management.
  • Offline Authentication: A refresh token is stored to allow the user to stay offline for up to 30 days without needing to log in again.
  • Token Storage: Tokens are saved using WebStorageStateStore combined with Shared Preferences for offline compatibility.
  • Platform-Specific Redirection: iOS uses localhost#/, while Android uses localhost/#/ due to a Quasar-specific behavior.

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

  • Can I use auth-js to implement exactly what I have already implemented? (different redirectUris, offline login)
  • Where do I have to start with the migration and what do I have to consider?
  • How extensive will the migration be and can I keep the current pattern?

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions