2
\$\begingroup\$

We are using Keycloak v4.4.0 as SSO for multiple microservices and SPAs. The main goal is to prevent user entering his credentials when landing to another SPA UI.

I've not worked with such authentication mechanism before, so I'm trying to use all its features.

This js module is compiled via webpack. decodeToken function taken from keycloak-js sources.

import Axios from "axios";

import * as Keycloak from '../../node_modules/keycloak-js';
export const apiClient = Axios.create({});

const tokenStoreName = 'keycloak.token', refreshTokenStoreName = 'keycloak.refreshToken', timeSkewStoreName = 'keycloak.timeSkew ';

var _keycloakInstance: Keycloak.KeycloakInstance;

function decodeToken(str) {
  str = str.split('.')[1];

  str = str.replace('/-/g', '+');
  str = str.replace('/_/g', '/');
  switch (str.length % 4)
  {
      case 0:
          break;
      case 2:
          str += '==';
          break;
      case 3:
          str += '=';
          break;
      default:
          throw 'Invalid token';
  }

  str = (str + '===').slice(0, str.length + (str.length % 4));
  str = str.replace(/-/g, '+').replace(/_/g, '/');

  str = decodeURIComponent(escape(atob(str)));

  str = JSON.parse(str);
  return str;
}

function checkRefreshTokenExpired(refreshTokenParsed){
  let nowInSeconds = new Date().getTime()/1000;
  return nowInSeconds >= refreshTokenParsed.exp // refresh token expired
}

function automaticTokenRefresh(){
  let refreshAfter = Math.round((_keycloakInstance.tokenParsed.exp - new Date().getTime()/1000) - 25);
  if(refreshAfter < 0)
    refreshAfter = 1;

  setTimeout(async () => {
    _tokenResolver.state = 0;
    await _tokenResolver.getToken();
    automaticTokenRefresh();
  }, refreshAfter*1000)
}

export function init(state){
  return new Promise(function(resolve, reject){
    let globalSettings = state.globalSettings;
    _keycloakInstance = Keycloak({
      url: globalSettings.idp.baseUrl, realm: globalSettings.idp.realm, clientId: globalSettings.serviceName,
    });

    let isIe = document['documentMode'] || /Edge/.test(navigator.userAgent);
    let token = localStorage.getItem(tokenStoreName);
    if(token === null || token === 'undefined')
      token = undefined;

    let refreshToken = localStorage.getItem(refreshTokenStoreName);
    if(refreshToken === null || refreshToken === 'undefined')
      refreshToken = undefined;

    let timeSkewString = localStorage.getItem(timeSkewStoreName);
    let timeSkew = undefined;
      if(timeSkewString === null || timeSkewString === 'NaN')
        timeSkew = undefined;
      else
        timeSkew = Number.parseInt(timeSkewString);

    if(Number.isNaN(timeSkew))
      timeSkew = undefined;

    if(refreshToken){
      let refreshTokenParsed = decodeToken(refreshToken);
      if(!refreshTokenParsed || !refreshTokenParsed.exp || checkRefreshTokenExpired(refreshTokenParsed)){
        token = undefined;
        refreshToken = undefined;
        timeSkew = undefined;
        clearTokenData();
      }
    }


    _keycloakInstance['onAuthLogout'] = function(){
      clearTokenData();
    }

    _keycloakInstance.init({onLoad: 'login-required', token, refreshToken, timeSkew, checkLoginIframe: !isIe}).success(function(authenticated){
      storeTokenData();

      if(!authenticated ||  !_keycloakInstance.token || _keycloakInstance.token == 'undefined')
      {
        logout();
        return;
      }

      automaticTokenRefresh();

      apiClient.interceptors.request.use(async function(config){ // before request
          let tokenData;
          try{
            tokenData = await _tokenResolver.getToken(); // get current token
          } catch(error) {
            logout();
            return Promise.reject(error);
          }

          if(!tokenData) {
            logout();
            return Promise.reject();
          }

          config.headers['Authorization'] = 'Bearer ' + tokenData; // set request auth token

          return config;
        }, (error) => {
          logout();
          return Promise.reject(error);
      });

      apiClient.interceptors.response.use(function(response) {
          return response;
        },async (error) => {
          if(error === undefined)
            return Promise.reject({message: 'error is undefined'});

          const originalRequest = error.config;
          if(error.response && error.response.status === 401 && !originalRequest._retry){ // if response is unathorized, and first attempt
            originalRequest._retry = true;
            _tokenResolver.state = 0;
            let tokenData = await _tokenResolver.getToken(); // get current token
            if(!tokenData) 
              return Promise.reject(error)

            originalRequest.headers['Authorization'] = 'Bearer ' + tokenData.access_token;
            return apiClient(originalRequest); // retry
          }

          return Promise.reject(error);
        }
      );

      resolve();
    }).error(function(){
      reject();
    });
  });
}

function storeTokenData(){
  localStorage.setItem(tokenStoreName, _keycloakInstance.token);
  localStorage.setItem(refreshTokenStoreName, _keycloakInstance.refreshToken);
  localStorage.setItem(timeSkewStoreName, _keycloakInstance.timeSkew.toString());
}

function clearTokenData(){
  localStorage.removeItem(tokenStoreName);
  localStorage.removeItem(refreshTokenStoreName);
  localStorage.removeItem(timeSkewStoreName);
}

export function logout(){
  clearTokenData();
  _keycloakInstance.logout();
}

const _tokenResolver = {
  state: 2,
  promise: undefined,
  result: undefined,
  getToken: async function(){
    if(!this.result) // init data
      this.result = _keycloakInstance.token;

    if(!this.result)
      return Promise.reject('keycloakInstance.token is empty');

    if(_keycloakInstance.isTokenExpired(0)) // token expired. Need to refresh
      this.state = 0;

    if(this.state === 0){
      this.state = 1;
      let that = this;
      this.promise = new Promise(function(resolve, reject){
        return _keycloakInstance.updateToken(-1) // refresh token
          .success(function(refreshed){
            storeTokenData();
            that.result = _keycloakInstance.token;
            that.state = 2; // set state to resolved
            resolve(that.result)
          }).error(function(){
            reject('Failed to refresh the token, or the session has expired');
          });
      })

      return this.promise;
    }
    else if(this.state === 1)
      return this.promise;
    else if(this.state === 2)
      return Promise.resolve(this.result);
  }
}

Function init is called once at application start. state object is passed outside and retrieved from API. isIe variable is created to prevent keycloak to use LoginIframe when browser is Internet Explorer or Edge, because bug with infinite refresh is reproducing and not fixed.

I'm saving values of token, refreshtoken and timeskew in local storage to prevent resetting session and need to login again if browser was closed. But also I'm checking if refresh token isn't expired.

On success keycloak init I'm triggering automatic forced token refresh and setting up my axios instance interceptors. Adding Bearer value to Authorization header and handling 401 error code on each response.

I'm wrapped all work with token in single place - tokenResolver. It helps prevent simultaneous requests to refresh token if it is expired and multiple clients try to make requests to API.

The code works as expected. How this code can be improved in terms of authentication? And in terms of keycloak js adapter best usage?

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.