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.
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:
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:
- The backend inserts a new row in the
impersonations
table, which will store a reference to the target user and a random token. - The admin will be redirected to a special route in the frontend that includes that temporary token in the query params.
- 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.
(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
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
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
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
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;
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);
}
};
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
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
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
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
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
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
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
and I added this scope in Impersonation
:
scope :no_longer_valid, -> { where(used: true).or(where('exchange_before < ?', Time.current)) }
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
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)