Impersonation with Nestjs

Impersonation is the ability of users to act as other users. Usually it consists in administrators of a system having the ability to interact as standard users on the same system. Gitlab, for instance, provides this feature. It is also part of the feature to provide a way to revert the impersonation.

In this post, we present one approach to implement this feature and we illustrate principles with code from Nestjs.

Assumptions

We assume you already have some sort of user session implementation: a cookie that either directly contains information (client-side cookie, signed with a secret) about the current user or an identifier that links to such information in a store.

We also assume that a REST API that filters in data that belong to the user.

With Nestjs, we will assume that the request object contains a property called user that contains the current information about the user. We will also assume that this property is filled in by a guard, based on the session. We will also assume that the session is implement with a client-side cookie that contains the current user identifier. We will also assume that TypeOrm is used to provide CRUD endpoints and that resources are filtered by user.

interface CustomRequest extends express.Request {
  user: User;
}
interface CustomSession {
  user {
    id: string;
  }
}
@Crud({
  model: {
    type: ...,
  },
})
@CrudAuth({
  property: 'user',
  filter: (user: User) => {
    return {
      userId: user.id,
    };
  },
})
@Controller('entity')
export class MyEntityController implements CrudController<...> {
 …
}

The last assumption is that the Nestjs application has a guard that checks whether the user is authenticated and that adds a user property to the request object.

@Injectable()
export class IsAuthenticatedGuard implements CanActivate {
  constructor(
    @InjectRepository(User) private users: Repository<User>,
  ) {}

  async canActivate(
    context: ExecutionContext,
  ): Promise<boolean> {
     const request = context.switchToHttp().getRequest();
    const session = request.session as CustomSession;
    if (!session.user) {
      return false;
    }
    request.user = await this.users.findOne({ where: { id: session.user.id }});
    return true;
  }
}

Principles

We are going to change as less code as possible but still make pretty explicit what happens. The main idea is to store two pieces of information in the user session:

  • the actual logged in user
  • the impersonated user

Therefore, we change the session structure to the following:

interface CustomSession {
  loggedInUser: {
    id: string;
  }
  impersonatedUser: {
    id: string
  }
}

We must know provide two API endpoints that enable the user to impersonate and to stop the impersonation.

  @Get('impersonate')
  removeImpersonation(@Session() session: CustomSession) {
    session.impersonatedUser = session.loggedInUser;
  }

  @Get('users/:id/impersonate')
  async impersonate(@Param('id') id: number, @Session() session: CustomSession) {
    const user = await this.users.findOne({ where: { id }});
    if (!user) {
      throw new NotFoundException();
    }
    session.impersonatedUser = user;
  }
The impersonation endpoints

Now, when logging in, we must fill in the two properties session.impersonatedUser and session.loggedInUser. Here is an example:

  @Post('/login')
  async login(@Session() session: CustomSession, @Body('username') username: string, @Body('password') password: string) {
    session.impersonatedUser = null;
    session.loggedInUser = null;
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    session.impersonatedUser = session.loggedInUser = this.serializeUser(user);
    return session.loggedInUser;
  }
The modified login route

Our authentication guard must fill in the request.user with the impersonated user.  Here is an example:

  async canActivate(
    context: ExecutionContext,
  ): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const session = request.session as CustomSession;
    if (!session.impersonatedUser) {
      return false;
    }
    request.user = await this.users.findOne({ where: { id: session.impersonatedUser.id }});
    return true;
  }
The modified authentication guard

Our authentication fills in the session with the logged in user and the impersonated user information. Our guard fills in the user property with the impersonated user from the user session. Our CRUD controllers can now filter data by user.

Let's walk through the mechanism.

  1. The user logs in. A POST request is sent to the login endpoint. The session is filled in with impersonatedUser and loggedInUser properties.
  2. The user starts impersonating by reaching the /admin/users/1234/impersonate endpoint. Once reached, the session is modified: the impersonatedUserId is filled in with information about user 1234.
  3. The user reaches a CRUD endpoint, let's say /entity. The authentication guard gets the impersonatedUserIdproperty from the session and adds a user property to the request object based on it.
  4. TypeOrm filters the entity by the information stored in the userproperty of the request object.
  5. The user stops the impersonation by reaching /admin/impersonate endpoint. The session is modified: the `impersonatedUserId` is filled in with the `loggedInUserId`.

Do not forget to protect the impersonation endpoint!

That's it! With this approach, we make minimal changes to the code base and we enable a great feature: Impersonation.


KM

Photo by Felicia Buitenwerf on Unsplash

Kevin Merckx

Kevin Merckx

Software Engineer, Real Full Stack Developer: from software architecture to development operations and programming.