DEV Community

Leti Esperón
Leti Esperón

Posted on

Impersonation feature using Rails and NextJS

In this article I'll show you how you can implement the feature to allow your internal admins to impersonate a user. 🥷

This is a tremendously valuable feature that most SaaS companies count with, in order to provide better support for customer requests. This feature allows admins to see what the customer is seeing and help them out. For early-stage startups, this feature holds even greater significance as it eliminates the need to develop numerous admin editing functionalities just to replicate basic user functions. Moreover, implementing this feature can be accomplished within a day's work, given a clear understanding of the required architecture, which we aim to explore in this blog post. 😘

The acceptance criteria might read as follows:

As an admin user, I should be able to access an "Impersonate" button on a user's profile. When I click this button, it should open the application in a new tab, where I am logged in as the selected user, allowing me to act on their behalf. For security reasons, the impersonation session should have a duration of 20 minutes, which is shorter compared to regular sign-in sessions, and usually all I need.

Image description


High-level architecture:

I'll first describe the overall architecture, which can hopefully serve as an inspiration to build this feature in any framework.

This solution is designed for a system that consists of a backend application with a relational database, and that communicates with a separate client application via HTTP, utilizing JWT for authentication.

Then, I'll exemplify the implementation for a system that uses a Ruby on Rails GraphQL API, ActiveAdmin gem for the admin dashboard, and a frontend built with NextJS (React).

So, let's get to it!


First, you need to create your impersonations table. Your data model might end up looking something like this:

Image description

You might want to add an index and constraint to ensure the token is unique.

The sequence is as follows:
When the admin clicks the "Impersonate" button:

  1. The backend inserts a new row in the impersonations table, which will store a reference to the target user and a random token.
  2. The admin will be redirected to a special route in the frontend that includes that temporary token in the query params.
  3. The frontend route will immediately make a request to the backend to exchange this temporary impersonation token for an actual JWT for the impersonated user. It will store it in the session storage and proceed as a regular sign in.

Image description

(Note: The diagram illustrates the sequence of actions, showcasing the use of a REST API for admin endpoints and a GraphQL API for the main frontend application. However, the focus here is on understanding the sequence rather than the details of the endpoints interfaces.)


Now let's proceed to implement this.

STEP 1: Impersonation model

First off, let's start with the migration to create the impersonations table:

class CreateImpersonations < ActiveRecord::Migration[7.0]
  def change
    create_table :impersonations do |t|
      t.references :user, null: false, foreign_key: true
      t.references :admin_user, null: false, foreign_key: true
      t.string :token, null: false
      t.datetime :exchange_before, null: false
      t.boolean :used, null: false, default: false

      t.timestamps
    end

    add_index :impersonations, :token, unique: true
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, let's define the model:

# app/models/impersonation.rb

class Impersonation < ApplicationRecord
  SESSION_DURATION = 20.minutes

  belongs_to :user
  belongs_to :admin_user

  validates :token, presence: true, uniqueness: true
  validates :exchange_before, presence: true

  scope :current, -> { where(used: false).where('exchange_before >= ?', Time.current) }

  def mark_used!
    update!(used: true)
  end

  def session_duration
    SESSION_DURATION
  end
end
Enter fullscreen mode Exit fullscreen mode

Don't forget to also define the relation in the other directions, on the user and admin_user models.

STEP 2: Button to impersonate

Now, let's proceed to implement the button to impersonate the user in the admin dashboard. As I mentioned, I am using ActiveAdmin gem for the admin backoffice, that has its own DSL to adding custom actions to resources.

# app/admin/users.rb

ActiveAdmin.register User do
  ...

  member_action :impersonate, method: :post do
    user = User.find(params[:id])

    impersonator = Users::Impersonations::Initiator.new(user: user,
                                                        admin_user: current_admin_user)
    impersonator.initiate!
    redirect_url = impersonator.redirect_url

    redirect_to redirect_url, allow_other_host: true
  end

  action_item :impersonate, only: :show do
    link_to('Impersonate', impersonate_admin_user_path(resource),
            method: :post, target: '_blank', rel: 'noopener')
  end
end
Enter fullscreen mode Exit fullscreen mode

I like using SRP service objects and grouping classes by domain, so I created a Users::Impersonations::Initiator class that will be responsible for creating the impersonation record and returning the url to which redirect the user to:

# app/concepts/users/impersonations/initiator.rb

# This class builds a url that to allow an admin user to impersonate a user.
# It creates a temporary token that the frontend will exchange for a more permanent JWT
# in a subsequent call.
# Since this temporary token travels in the query params, it is insecure and therefore
# only valid for a couple minutes and can be used only once.

module Users
  module Impersonations
    class Initiator
      EXCHANGE_EXPIRES_IN = 2.minutes

      def initialize(user:, admin_user:)
        @user = user
        @admin_user = admin_user
      end

      def initiate!
        ::Impersonation.create!(user: user,
                                admin_user: admin_user,
                                token: token,
                                exchange_before: EXCHANGE_EXPIRES_IN.from_now)
      end

      def redirect_url
        base_url = URI.parse(frontend_url)
        base_url.path = '/impersonate'
        base_url.query = { token: token }.to_query

        base_url.to_s
      end

      private

      attr_reader :user, :admin_user

      def token
        @token ||= SecureRandom.hex(32)
      end

      def frontend_url
        ENV.fetch('FRONTEND_URL', 'https://app.village.com')
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

STEP 3: Frontend impersonation route

Now we have the first part of the sequence ready. We should move on to the frontend side. We want to make it so that hitting /impersonate?token=xxx makes a request to the backend, stores the returned user session info, and redirects to the homepage. We'll first trigger a sign out in case there was a previous session in progress, to make sure all the post-logout cleanup is done.

For example, in our NextJS app this could look something like this:

// src/pages/impersonate/index.js

import * as userActions from "../../../store/actions/userActions";

const ImpersonatePage = () => {
  const dispatch = useDispatch();
  const router = useRouter();
  const madeRequest = useRef(false);

  useEffect(() => {
    const impersonate = async () => {
      const token = router.query.token;

      if(madeRequest.current) {
        return;
      }

      if (token) {
        madeRequest.current = true;

        await dispatch(userActions.logout({ skipToast: true })); 
        await dispatch(userActions.impersonate(token));
      }

      router.replace("/");
    };

    impersonate();
  }, [router.query.token]);

  return (
    <Head>
      <title>MyApp | Impersonating</title>
    </Head>
  );
};

export default ImpersonatePage;
Enter fullscreen mode Exit fullscreen mode

My backend will provide a graphQL mutation called ImpersonateUser that the fronted will be invoking. The jwt is an available field in the returned user type:

// store/actions/userActions.js

...

export const impersonate = (token) => async (dispatch, getState) => {
  const gqlQuery = {
    query: `
      mutation ImpersonateUser($token: String!) {
        impersonateUser(token: $token) {
          errors
          user {
            id 
            jwt
            name
            email
          }
        }
      }
    `,
    variables: {
      token,
    },
  };

  try {
    const data = await helper(gqlQuery);
    const responseData = data.impersonateUser;
    const errors = responseData.errors;
    const userData = responseData.user;

    if (!errors && userData) {
      dispatch({
        type: USER_IMPERSONATION,
        userInfo: userData,
      });

      await handleSuccessfulUserLogin(dispatch, getState, userData); // Do your thing here...
    } else {
      dispatch({
        type: USER_IMPERSONATION_FAIL,
        errors: errors,
      });

      errorMessageExtractor(errors);
    }
  } catch (err) {
    dispatch({
      type: USER_IMPERSONATION_FAIL,
      errors: err,
    });

    handleServerErrors(err);
  }
};
Enter fullscreen mode Exit fullscreen mode

STEP 4: Backend endpoint to exchange impersonation token

The mutation could look something like this:

# app/graphql/mutations/user/impersonate_user.rb

# Given a temporary impersonation token,
# allows retrieving a CurrentUser (and its corresponding longer-lasting JWT)

module Mutations
  module User
    class ImpersonateUser < Mutations::BaseMutation
      argument :token, String, required: true

      field :user, Types::CurrentUserType, null: true
      field :errors, Types::JsonType, null: true

      def resolve(token:)
        impersonator = ::Users::Impersonations::Authenticator.new(impersonation_token: token)
        impersonator.authenticate

        return { errors: impersonator.errors } unless impersonator.success?

        context[:current_user] = impersonator.user
        context[:impersonation] = impersonator.impersonation

        { user: impersonator.user }
      end

      def self.authorized?(_object, _context)
        true # needed because my BaseMutation requires a signed in user by default, so we're overriding. 
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

My Types::CurrentUserType could look something like this:

# app/graphql/types/current_user_type.rb

module Types
  class CurrentUserType < ApplicationRecordType
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: false

    field :jwt, String, null: true

    def jwt
      if context[:impersonation].present?
        AuthToken.token(object, expires_in: context[:impersonation].session_duration)
      else
        AuthToken.token(object)
      end
    end

    def self.authorized?(object, context)
      context[:current_user] == object
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Observe how we are leveraging context[:impersonation] that we set on the mutation, to make it so that the impersonations sessions are valid for just 20 minutes, instead of the default.

I have a service class AuthToken that I use to create my JWTs. I won't dive into the details of it since it's off topic.

And that should be it!


STEP 5: Tests!

ActiveAdmin tests: Assert the button is showing:

# spec/controllers/admin/users_controller_spec.rb

RSpec.describe Admin::UsersController do
  render_views
  let(:page) { Capybara::Node::Simple.new(response.body) }

  describe 'GET show' do
    subject(:make_request) { get :show, params: { id: user.id } }
    let!(:user) { create(:user) }
    before do
      login_admin
    end

    it 'renders the user info and button to impersonate', :aggregate_failures do
      make_request

      expect(response).to have_http_status(:success)

      expect(page).to have_content(user.first_name)
      expect(page).to have_content(user.last_name)
      expect(page).to have_content(user.email)

      expect(page).to have_link('Impersonate', href: impersonate_admin_user_path(user))
    end
  end
end 
Enter fullscreen mode Exit fullscreen mode

Initiator service class test:

# spec/concepts/users/impersonations/initiator_spec.rb

RSpec.describe Users::Impersonations::Initiator do
  let(:initiator) { described_class.new(user: user, admin_user: admin_user) }

  let(:user) { create(:user) }
  let(:admin_user) { create(:admin_user) }

  before do
    ENV['FRONTEND_URL'] = 'https://my-frontend.com'
  end

  after do
    ENV['FRONTEND_URL'] = nil
  end

  it 'creates an impersonation and exposes a redirect url' do
    expect {
      initiator.initiate!
    }.to change(Impersonation, :count).by(1)

    impersonation = Impersonation.last

    expect(impersonation.user).to eq(user)
    expect(impersonation.admin_user).to eq(admin_user)
    expect(impersonation.token).to be_present
    expect(impersonation.exchange_before).to be_present

    expect(initiator.redirect_url)
      .to eq("https://my-frontend.com/impersonate?token=#{impersonation.token}")
  end
end
Enter fullscreen mode Exit fullscreen mode

Mutation unit test:

# spec/graphql/mutations/user/impersonate_user_spec.rb

RSpec.describe Mutations::User::ImpersonateUser, type: :request do
  subject(:make_request) do
    make_graphql_request(
      query: query,
      variables: variables
    )
  end

  let(:user) { nil }

  let(:query) do
    <<-GRAPHQL
      mutation impersonateUser($token: String!) {
        impersonateUser(token: $token) {
          errors
          user {
            email
            jwt
            isVerified
          }
        }
      }
    GRAPHQL
  end

  let(:variables) do
    {
      token: impersonation_token
    }
  end

  let(:impersonation_token) { 'sometoken' }

  let(:authenticator_double) do
    instance_double(Users::Impersonations::Authenticator,
                    authenticate: nil,
                    success?: true,
                    errors: nil,
                    user: impersonated_user,
                    impersonation: stubbed_impersonation)
  end

  let(:stubbed_impersonation) { create(:impersonation, user: impersonated_user) }
  let(:impersonated_user) { create(:user) }

  before do
    allow(Users::Impersonations::Authenticator)
      .to receive(:new).with(impersonation_token: impersonation_token)
      .and_return(authenticator_double)

    allow(AuthToken).to receive(:token)
      .with(impersonated_user, expires_in: 20.minutes)
      .and_return('somejwt')
  end

  it 'returns verified user' do
    make_request

    expect(json_body.dig('impersonateUser', 'errors')).to be_nil
    expect(json_body.dig('impersonateUser', 'user', 'jwt')).to eq('somejwt')
    expect(json_body.dig('impersonateUser', 'user', 'email')).to eq(impersonated_user.email)
  end

  context 'when the authenticator returns an error' do
    let(:error_message) { 'some error' }

    let(:authenticator_double) do
      instance_double(Users::Impersonations::Authenticator,
                      authenticate: nil,
                      success?: false,
                      errors: { impersonationToken: error_message })
    end

    it 'returns the error' do
      make_request

      expect(json_body.dig('impersonateUser', 'errors', 'impersonationToken')).to eq(error_message)
      expect(json_body.dig('impersonateUser', 'user')).to be_nil
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Authenticator service test:

# spec/concepts/users/impersonations/authenticator_spec.rb

RSpec.describe Users::Impersonations::Authenticator do
  let(:authenticator) { described_class.new(impersonation_token: impersonation_token) }

  let!(:impersonation) { create(:impersonation, user: user, token: impersonation_token) }
  let(:impersonation_token) { 'foobar1234' }
  let(:user) { create(:user) }

  it 'finds the impersonation by token and marks it as used' do
    authenticator.authenticate

    expect(authenticator).to be_success

    expect(impersonation.reload).to be_used
    expect(authenticator.user).to eq(user)
    expect(authenticator.admin_user).to eq(impersonation.admin_user)
  end

  context 'when the impersonation by that token has expired' do
    let!(:impersonation) do
      create(:impersonation, :expired, user: user, token: impersonation_token)
    end

    it 'returns an error' do
      authenticator.authenticate

      expect(authenticator).to be_failure
      expect(authenticator.errors[:impersonation_token]).to eq('is invalid')
    end
  end

  context 'when the impersonation by that token has been used' do
    let!(:impersonation) do
      create(:impersonation, :used, user: user, token: impersonation_token)
    end

    it 'returns an error' do
      authenticator.authenticate

      expect(authenticator).to be_failure
      expect(authenticator.errors[:impersonation_token]).to eq('is invalid')
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Bonus: Worker to clean up old impersonation records

Having the old impersonation records in the database makes a nice audit trail that might come useful for debugging and auditing. However, if logs are enough to you maybe you prefer to periodically clean up these records and free up some DB space. In that case, you could implement a scheduler job that runs periodically.

Here's how I did mine, using sidekiq:

# app/concepts/users/impersonations/cleaner_worker.rb

# Removes expired or already used impersonation tokens in order to free database space.

module Users
  module Impersonations
    class CleanerWorker
      include Sidekiq::Worker

      def perform
        ::Impersonation.no_longer_valid.destroy_all
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

and I added this scope in Impersonation:

  scope :no_longer_valid, -> { where(used: true).or(where('exchange_before < ?', Time.current)) }
Enter fullscreen mode Exit fullscreen mode

The test is super straightforward as well:

# spec/concepts/users/impersonations/cleaner_worker_spec.rb

RSpec.describe Users::Impersonations::CleanerWorker do
  let(:worker) { described_class.new }

  let!(:expired_impersonation) { create(:impersonation, :expired) }
  let!(:used_impersonation) { create(:impersonation, :used) }
  let!(:valid_impersonation) { create(:impersonation) }

  it 'removes expired or already used impersonation tokens' do
    expect {
      worker.perform
    }.to change(Impersonation, :count).by(-2)

    expect(Impersonation.all).to contain_exactly(valid_impersonation)
  end
end
Enter fullscreen mode Exit fullscreen mode

I hope this gave you inspiration on how you can implement the impersonation feature. It is really a very simple feature that most SaaS tools have. You don't need to get fancy and reinvent the wheel.

But honestly, I just used this post as an excuse to exemplify what I think good service objects look like and my approach to unit testing mutations. Cheers! ✨

Top comments (0)