DEV Community

Abdur Rakib Rony
Abdur Rakib Rony

Posted on

Implementing Secure Single Sign-On Between Vue & NodeJs Applications

Introduction
Single Sign-On (SSO) is a crucial feature in modern web applications that allows users to navigate between related systems without the friction of multiple logins. In this article, I'll walk through how to implement a secure SSO mechanism between two vue and nodejs applications using AES-256-CBC encryption.
The implementation I'm sharing came from a real-world project where we needed to allow users to seamlessly switch between a Client Panel and a Partner/IB Panel while maintaining proper authentication and security.

The Problem
Our application had two separate panels:

  1. Client Panel: Where regular users manage their accounts
  2. Partner/IB Panel: Where partners (Introducing Brokers) manage their business

Some users had access to both panels, and we wanted to provide a smooth experience for them to switch between panels without having to log in multiple times.

Solution Overview
Our solution uses:

  • AES-256-CBC encryption to securely pass user identity between panels
  • Shared encryption key stored in the database
  • Token-based authentication to maintain separate sessions in each panel
  • Vue.js and Vuetify for the frontend components

The basic flow works like this:

  • User clicks "Switch to Partner Panel" in the Client Panel
  • Client Panel encrypts the user's ID and redirects to Partner Panel with the encrypted token
  • Partner Panel decrypts the token, verifies the user, and logs them in automatically
  • The same process works in reverse for switching back to the Client Panel

Database Setup
First, we need to set up the database to store our encryption key and panel URLs:

  1. Add an encryption key to the broker_settings table:
INSERT INTO broker_settings (name, value)
VALUES ('CUSTOM_ENCRYPTION_KEY', '66f7a1a9-675c-8010-b25a-48230661874a');
Enter fullscreen mode Exit fullscreen mode
  1. Ensure you have the panel URLs in your general_informations table:
-- Make sure these columns exist
ALTER TABLE general_informations ADD COLUMN client_portal_url VARCHAR(255);
ALTER TABLE general_informations ADD COLUMN partner_portal_url VARCHAR(255);

-- Set the URLs
UPDATE general_informations 
SET client_portal_url = 'https://client.example.com',
    partner_portal_url = 'https://partner.example.com'
WHERE id = 1;
Enter fullscreen mode Exit fullscreen mode

Backend Implementation
Client Panel AuthController Methods
Let's add three methods to the Client Panel's AuthController:

  1. switchToPartnerPanel: Generates an encrypted token and provides the URL to redirect to the Partner Panel
async switchToPartnerPanel({ auth, response }) {
  try {
    // Ensure user is authenticated
    const user = await auth.getUser()
    if (!user) {
      return response.status(401).json({
        type: 'error',
        message: 'User not authenticated'
      })
    }

    // Check if user has IB status (partner privileges)
    if (!user.ib_status || user.ib_status !== 1) {
      return response.status(403).json({
        type: 'error',
        message: 'You do not have access to the Partner Panel'
      })
    }

    // Get encryption key from database
    const encryptionKey = await BrokerSitting.query()
      .where('name', 'CUSTOM_ENCRYPTION_KEY')
      .first()

    if (!encryptionKey || !encryptionKey.value) {
      return response.status(500).json({
        type: 'error',
        message: 'SSO configuration error'
      })
    }

    // Create a proper 32-byte key using SHA-256
    const crypto = require('crypto')
    const hash = crypto.createHash('sha256')
    hash.update(encryptionKey.value)
    const keyBuffer = hash.digest() // This gives a 32-byte buffer

    // Use the first 16 bytes of the key as the IV
    const iv = keyBuffer.slice(0, 16)

    // Get user ID to encrypt
    const userId = user.id

    // Encrypt the user ID using AES-256-CBC
    const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv)
    let token = cipher.update(String(userId), 'utf8', 'base64')
    token += cipher.final('base64')

    // Get partner panel URL from database
    const partnerPortalInfo = await Database
      .table('general_informations')
      .select('partner_portal_url')
      .first()

    if (!partnerPortalInfo || !partnerPortalInfo.partner_portal_url) {
      return response.status(500).json({
        type: 'error',
        message: 'Partner portal URL not configured'
      })
    }

    // Create the SSO URL with the encrypted token
    const ssoUrl = `${partnerPortalInfo.partner_portal_url.replace(/\/$/, '')}/sso-login?token=${encodeURIComponent(token)}`

    // Return the URL to redirect to
    return response.status(200).json({
      type: 'success',
      ssoUrl
    })
  } catch (error) {
    console.error('SSO error:', error)
    return response.status(500).json({
      type: 'error',
      message: 'Internal server error',
      error: process.env.NODE_ENV === 'development' ? error.message : undefined
    })
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. handleSSOLogin: Receives an encrypted token from the Partner Panel, decrypts it, and logs the user in
async handleSSOLogin({ request, response, auth, session }) {
  try {
    const { token } = request.all()

    if (!token) {
      return response.status(400).json({
        type: 'error',
        message: 'SSO token is required'
      })
    }

    // Get encryption key from database
    const encryptionKey = await BrokerSitting.query()
      .where('name', 'CUSTOM_ENCRYPTION_KEY')
      .first()

    if (!encryptionKey || !encryptionKey.value) {
      return response.status(500).json({
        type: 'error',
        message: 'SSO configuration error'
      })
    }

    // Create a proper 32-byte key using SHA-256
    const crypto = require('crypto')
    const hash = crypto.createHash('sha256')
    hash.update(encryptionKey.value)
    const keyBuffer = hash.digest() // This gives a 32-byte buffer

    // Use the first 16 bytes of the key as the IV
    const iv = keyBuffer.slice(0, 16)

    // Decrypt the token to get user ID
    let userId
    try {
      const decipher = crypto.createDecipheriv('aes-256-cbc', keyBuffer, iv)
      userId = decipher.update(token, 'base64', 'utf8')
      userId += decipher.final('utf8')
    } catch (error) {
      return response.status(400).json({
        type: 'error',
        message: 'Invalid SSO token'
      })
    }

    // Find the user in database
    const user = await Account.query()
      .where('id', userId)
      .first()

    if (!user) {
      return response.status(404).json({
        type: 'error',
        message: 'User not found'
      })
    }

    // Check if user is active
    if (user.status === 0) {
      return response.status(403).json({
        type: 'error',
        message: 'Account is blocked'
      })
    }

    // Check if email is verified
    if (user.email_status === 0) {
      return response.status(403).json({
        type: 'error',
        message: 'Email not verified'
      })
    }

    // Log in the user
    const authToken = await auth.generate(user)

    // Record login activity
    const now = moment().format('YYYY-MM-DD HH:mm:ss')
    const userAgentString = request.header('user-agent')
    const userAgent = UserAgent.parse(userAgentString)
    const userDevice = `${userAgent.family} ${userAgent.major}`

    const sessionId = uuidv4()
    session.put('loginActivitySessionId', sessionId)

    const userActivityService = new LoginActivityService()
    await userActivityService.logLoginActivity(
      user.id,
      1, // Panel = 1 for client panel
      sessionId,
      now,
      null,
      userDevice,
      request.header('referer'),
      request.ip()
    )

    // Get logout time from settings
    const logOutTimeSetting = await BrokerSitting.query()
      .where('name', 'log_out_time_client')
      .first()

    const logOutTime = logOutTimeSetting ?
      parseInt(logOutTimeSetting.value) * 60 * 1000 :
      30 * 60 * 1000 // Default to 30 minutes

    return response.status(200).json({
      type: 'success',
      message: 'SSO login successful',
      logOutTime: logOutTime,
      client: authToken
    })
  } catch (error) {
    console.error('SSO login error:', error)
    return response.status(500).json({
      type: 'error',
      message: 'Internal server error',
      error: process.env.NODE_ENV === 'development' ? error.message : undefined
    })
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. logoutAndRedirectToPartner: Logs the user out of the Client Panel and redirects to the Partner Panel's logout page
async logoutAndRedirectToPartner({ auth, response }) {
  const loginUser = await auth.user.id
  const loginActivity = await LoginActivity.query()
    .where('user_id', loginUser)
    .where('panel', 1)
    .where('logout_time', null)
    .orderBy('id', 'desc')
    .first()

  if (loginActivity) {
    const userActivityService = new LoginActivityService()
    await userActivityService.logLogoutActivity(
      loginUser,
      1,
      loginActivity.session_id
    )
  }

  await auth.logout()

  // Get partner panel URL from database
  const partnerPortalInfo = await Database
    .table('general_informations')
    .select('partner_portal_url')
    .first()

  if (!partnerPortalInfo || !partnerPortalInfo.partner_portal_url) {
    return response.status(500).json({
      type: 'error',
      message: 'Partner portal URL not configured'
    })
  }

  return response.status(200).json({
    type: 'success',
    message: 'Logged out successfully',
    redirectUrl: `${partnerPortalInfo.partner_portal_url.replace(/\/$/, '')}/logout`
  })
}
Enter fullscreen mode Exit fullscreen mode

Partner Panel AuthController Methods
Now let's implement the corresponding methods in the Partner Panel's AuthController:

  1. handleSSOLogin: Receives an encrypted token from the Client Panel
async handleSSOLogin({ request, response, auth, session }) {
  try {
    const { token } = request.all();

    if (!token) {
      return response.status(400).json({
        type: 'error',
        message: 'SSO token is required'
      });
    }

    // Get encryption key from database
    const encryptionKey = await BrokerSitting.query()
      .where('name', 'CUSTOM_ENCRYPTION_KEY')
      .first();

    if (!encryptionKey || !encryptionKey.value) {
      return response.status(500).json({
        type: 'error',
        message: 'SSO configuration error'
      });
    }

    // Create a proper 32-byte key using SHA-256
    const crypto = require('crypto');
    const hash = crypto.createHash('sha256');
    hash.update(encryptionKey.value);
    const keyBuffer = hash.digest();

    // Use the first 16 bytes of the key as the IV
    const iv = keyBuffer.slice(0, 16);

    // Decrypt the token to get user ID
    let userId;
    try {
      const decipher = crypto.createDecipheriv('aes-256-cbc', keyBuffer, iv);
      userId = decipher.update(token, 'base64', 'utf8');
      userId += decipher.final('utf8');
    } catch (error) {
      console.error('Token decryption error:', error);
      return response.status(400).json({
        type: 'error',
        message: 'Invalid SSO token'
      });
    }

    // Find the user in database
    const user = await Account.query()
      .where('id', userId)
      .first();

    if (!user) {
      return response.status(404).json({
        type: 'error',
        message: 'User not found'
      });
    }

    // Verify the user has IB status
    if (!user.ib_status || user.ib_status !== 1) {
      return response.status(403).json({
        type: 'error',
        message: 'You do not have access to the Partner Panel'
      });
    }

    // Check if user is active
    if (user.status === 0) {
      return response.status(403).json({
        type: 'error',
        message: 'Account is blocked'
      });
    }

    // Log in the user
    await auth.loginViaId(user.id);

    // Record login activity
    const now = moment().format("YYYY-MM-DD HH:mm:ss");
    const userAgentString = request.header("user-agent");
    const userAgent = UserAgent.parse(userAgentString);
    const userDevice = `${userAgent.family} ${userAgent.major}`;

    const sessionId = uuidv4();
    session.put("loginActivitySessionId", sessionId);

    const userActivityService = new LoginActivityService();
    await userActivityService.logLoginActivity(
      user.id,
      2, // Panel = 2 for partner panel
      sessionId,
      now,
      null,
      userDevice,
      request.header("referer"),
      request.ip()
    );

    // Redirect to dashboard
    return response.redirect('/dashboard');
  } catch (error) {
    console.error('SSO login error:', error);
    return response.status(500).json({
      type: 'error',
      message: 'Internal server error'
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. switchToClientPanel: Generates an encrypted token and provides the URL to redirect to the Client Panel
async switchToClientPanel({ auth, response }) {
  try {
    // Ensure user is authenticated
    const user = await auth.getUser();

    if (!user) {
      return response.status(401).json({
        type: 'error',
        message: 'User not authenticated'
      });
    }

    // Get encryption key from database
    const encryptionKey = await BrokerSitting.query()
      .where('name', 'CUSTOM_ENCRYPTION_KEY')
      .first();

    if (!encryptionKey || !encryptionKey.value) {
      return response.status(500).json({
        type: 'error',
        message: 'SSO configuration error'
      });
    }

    // Create a proper 32-byte key using SHA-256
    const crypto = require('crypto');
    const hash = crypto.createHash('sha256');
    hash.update(encryptionKey.value);
    const keyBuffer = hash.digest();

    // Use the first 16 bytes of the key as the IV
    const iv = keyBuffer.slice(0, 16);

    // Get user ID to encrypt
    const userId = user.id;

    // Encrypt the user ID using AES-256-CBC
    const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv);
    let token = cipher.update(String(userId), 'utf8', 'base64');
    token += cipher.final('base64');

    // Get client panel URL from database
    const clientPortalInfo = await Database
      .table('general_informations')
      .select('client_portal_url')
      .first();

    if (!clientPortalInfo || !clientPortalInfo.client_portal_url) {
      return response.status(500).json({
        type: 'error',
        message: 'Client portal URL not configured'
      });
    }

    // Create the SSO URL with the encrypted token
    const ssoUrl = `${clientPortalInfo.client_portal_url.replace(/\/$/, '')}/sso-login?token=${encodeURIComponent(token)}`;

    // Return the URL to redirect to
    return response.status(200).json({
      type: 'success',
      ssoUrl
    });
  } catch (error) {
    console.error('SSO error:', error);
    return response.status(500).json({
      type: 'error',
      message: 'Internal server error'
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. logoutAndRedirectToClient: Logs the user out of the Partner Panel and redirects to the Client Panel
async logoutAndRedirectToClient({ auth, response, session }) {
  try {
    const user = await auth.getUser();
    const userId = user.id;

    // Log logout activity
    const loginActivity = await LoginActivity.query()
      .where('user_id', userId)
      .where('panel', 2) // Partner panel
      .where('logout_time', null)
      .orderBy('id', 'desc')
      .first();

    if (loginActivity) {
      const userActivityService = new LoginActivityService();
      await userActivityService.logLogoutActivity(
        userId,
        2, // Partner panel
        loginActivity.session_id
      );
    }

    // Perform logout
    await auth.logout();
    session.clear();

    // Get client panel URL
    const clientPortalInfo = await Database
      .table('general_informations')
      .select('client_portal_url')
      .first();

    // Redirect to client panel
    if (clientPortalInfo && clientPortalInfo.client_portal_url) {
      return response.status(200).json({
        type: 'success',
        message: 'Logged out successfully',
        redirectUrl: `${clientPortalInfo.client_portal_url.replace(/\/$/, '')}/login`
      });
    } else {
      return response.status(200).json({
        type: 'success',
        message: 'Logged out successfully'
      });
    }
  } catch (error) {
    console.error('Logout error:', error);
    return response.status(500).json({
      type: 'error',
      message: 'Internal server error'
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Setting Up Routes
Add these routes to your Client Panel's routes file:

// Client Panel routes
Route.get('/switch-to-partner', 'AuthController.switchToPartnerPanel').middleware(['auth'])
Route.get('/sso-login', 'AuthController.handleSSOLogin')
Route.get('/logout-to-partner', 'AuthController.logoutAndRedirectToPartner').middleware(['auth'])
Enter fullscreen mode Exit fullscreen mode

And these routes to your Partner Panel's routes file:

// Partner Panel routes
Route.get('/sso-login', 'AuthController.handleSSOLogin')
Route.get('/switch-to-client', 'AuthController.switchToClientPanel').middleware(['auth'])
Route.get('/logout', 'AuthController.logoutAndRedirectToClient').middleware(['auth'])
Enter fullscreen mode Exit fullscreen mode

Frontend Implementation
Now let's implement the UI components to trigger the panel switching.

Client Panel Vue Component
Create a component for the Client Panel that allows switching to the Partner Panel:

<template>
  <div>
    <v-btn
      text
      :loading="isLoading"
      :disabled="isLoading"
      @click="switchToPartnerPanel"
      class="mx-2"
    >
      <v-icon left>mdi-swap-horizontal</v-icon>
     Switch to Partner Panel
    </v-btn>

    <v-snackbar
      v-model="showError"
      color="error"
      timeout="5000"
      bottom
    >
      {{ error }}
      <template v-slot:action="{ attrs }">
        <v-btn
          text
          v-bind="attrs"
          @click="showError = false"
        >
          Close
        </v-btn>
      </template>
    </v-snackbar>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  name: 'PanelSwitcher',
  computed: {
    ...mapState('panelSwitcher', ['isLoading', 'error']),
    showError: {
      get() {
        return this.error !== null;
      },
      set(value) {
        if (!value) {
          this.$store.commit('panelSwitcher/SET_ERROR', null);
        }
      }
    }
  },
  methods: {
    ...mapActions('panelSwitcher', ['switchToPartnerPanel'])
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Partner Panel Vue Component
Create a similar component for the Partner Panel to switch back to the Client Panel:

<template>
  <div>
    <v-btn
      text
      :loading="isLoading"
      :disabled="isLoading"
      @click="switchToClientPanel"
      class="mx-2"
    >
      <v-icon left>mdi-swap-horizontal</v-icon>
      {{ $t('clientPanel') || 'Switch to Client Panel' }}
    </v-btn>

    <v-snackbar
      v-model="showError"
      color="error"
      timeout="5000"
      bottom
    >
      {{ error }}
      <template v-slot:action="{ attrs }">
        <v-btn
          text
          v-bind="attrs"
          @click="showError = false"
        >
          Close
        </v-btn>
      </template>
    </v-snackbar>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  name: 'PanelSwitcher',
  computed: {
    ...mapState('panelSwitcher', ['isLoading', 'error']),
    showError: {
      get() {
        return this.error !== null;
      },
      set(value) {
        if (!value) {
          this.$store.commit('panelSwitcher/SET_ERROR', null);
        }
      }
    }
  },
  methods: {
    ...mapActions('panelSwitcher', ['switchToClientPanel'])
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Vuex Store Module
Create a Vuex store module for each panel to handle the panel switching:

// Client Panel: store/modules/panelSwitcher.js
import toast from "@/services/Notification";
import HTTP from "@/_helpers/http";

// State
const state = {
  ssoUrl: null,
  isLoading: false,
  error: null
};

// Getters
const getters = {};

// Mutations
const mutations = {
  SET_LOADING(state, isLoading) {
    state.isLoading = isLoading;
  },
  SET_ERROR(state, error) {
    state.error = error;
  },
  SET_SSO_URL(state, url) {
    state.ssoUrl = url;
  }
};

// Actions
const actions = {
  async switchToPartnerPanel({ commit }) {
    commit('SET_LOADING', true);
    commit('SET_ERROR', null);

    try {
      const response = await HTTP({
        trackProgress: true,
        loadingText: 'Preparing panel switch...'
      }).get('/switch-to-partner');

      if (response.status === 200 && response.data.type === 'success') {
        commit('SET_SSO_URL', response.data.ssoUrl);
        // Redirect to partner panel
        window.location.href = response.data.ssoUrl;
      } else {
        commit('SET_ERROR', response.data.message || 'Failed to switch panel');
        toast.error(response.data.message || 'Failed to switch panel');
      }
    } catch (error) {
      console.error(error);
      commit('SET_ERROR', 'Connection error, please try again');
      toast.error('Connection error, please try again');
    } finally {
      commit('SET_LOADING', false);
    }
  }
};

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
};
Enter fullscreen mode Exit fullscreen mode

Create a similar module for the Partner Panel, but with **switchToClientPanel **instead.

Security Considerations
This implementation includes several security measures:

  1. Secure Key Management: The encryption key is stored in the database, not hardcoded in the application
  2. Strong Encryption: We use AES-256-CBC with a proper 32-byte key derived from SHA-256
  3. Minimal Data Transfer: Only the user ID is encrypted and passed between panels
  4. Permission Verification: Users must have the proper permissions to access each panel
  5. Activity Logging: All login and logout activities are recorded
  6. Error Handling: Comprehensive error handling prevents security issues from improper use

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (0)

AWS Security LIVE!

Join us for AWS Security LIVE!

Discover the future of cloud security. Tune in live for trends, tips, and solutions from AWS and AWS Partners.

Learn More

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, valued within the supportive DEV Community. Coders of every background are welcome to join in and add to our collective wisdom.

A sincere "thank you" often brightens someone’s day. Share your gratitude in the comments below!

On DEV, the act of sharing knowledge eases our journey and fortifies our community ties. Found value in this? A quick thank you to the author can make a significant impact.

Okay