DEV Community

Cover image for Securing Your Serverless GraphQL API - Part III
Tanja Bayer for Cubesoft GmbH

Posted on

Securing Your Serverless GraphQL API - Part III

Welcome back, serverless security trailblazers! We've come a long way in our 'From Zero to Serverless Hero' series. In our previous chapter, we transitioned from the theoretical realm into the tangible world of implementation, securing our serverless GraphQL API with the power of AWS Cognito and a strong authorization setup.

In today's session, we push the boundaries of our serverless security prowess further. We're set to embark on a deeper exploration of the fascinating world of authorization. We're not just concerned with who can access our serverless API - but also the level and extent of their access. We'll decipher how to devise different access levels, meticulously tailoring permissions based on the roles of individual users.

This installment promises to be both challenging and enlightening, delving into the complexities of managing user permissions, all to add an extra layer of fortification to our serverless GraphQL API. At the end of this post, our serverless security expertise will have broadened significantly, and we'll be a few steps closer to reaching our ultimate goal.

Are you ready to level up your serverless security skills and go beyond the basics of API security? Let's move forward in our journey towards becoming the true serverless security heroes we aspire to be! It's time to roll up our sleeves, dive deep, and get started. The serverless saga continues!

Table of Contents

Creating Custom Scopes

Before diving into the implementation part, let's quickly have a look on the theory. What are scopes and what are they used for? Scopes are like keys to the doors of our applications, opening the right passageways for users and restricting unnecessary or dangerous access.

In the OIDC world, scopes are used to specify what access a client has to a user's details and what information is available from the user's ID token and userinfo endpoint. They act as a mechanism for the client to express the desired granularity of requested permissions.

For example, a client could request the 'profile' scope to access the user's default profile information, such as name, picture, and locale. Or the client could request the 'email' scope to fetch user's email address. These scopes allow clients to function with the necessary user information while respecting the principle of least privilege - the concept that a client should only have access to the information it absolutely needs to perform its function.

Understanding and implementing scopes in your OIDC configuration is an important step in creating a secure and efficient serverless infrastructure. It allows for better control and a more personalized user experience.

You might be wondering: why are we going to implement custom scopes in our AWS Cognito setup, shouldn't Cognito support it out of the box because it is part of the OpenID definitions? Good question! Let's explore the rationale behind this move.

In the realm of AWS Cognito, scopes are akin to permissions that are attached to an OAuth 2.0 token. When an application requires an access token from AWS Cognito, it specifies the scopes it requires. These scopes correlate to the specific API operations the application needs to execute.

However, it's important to understand that, within AWS Cognito, scopes are not assigned on a per-user basis. Rather, they're linked to an application client or a resource server. This means that every user of a particular application client has access to the same scopes, which are defined by the client or by the resource server connected to that client.

Now, you might be wondering, what if I need user-specific permissions? This is a common requirement in many applications, but AWS Cognito itself doesn't provide this granularity. To handle this, you would typically need to include this logic within your application or API.

Consider a scenario where a request is made to your API. This request includes an access token from AWS Cognito. Upon receiving the request, your API would first verify this token, then extract the identity of the user. Once the user's identity is confirmed, your application would then use this identity to look up the specific permissions that apply to this user within your system.

This is precisely why we are venturing into the domain of custom scopes. Our goal is to have an efficient, robust system that allows us to manage permissions at a granular level, ultimately delivering a secure and user-specific experience.

Now we have reached the point where we need to update our UserModel, because it we will use it to store and check the scopes a user has:

import { Model, PartitionKey } from '@cubesoft/dynamo-easy';
import { ScopeEnum } from '../enums/scope.enum';

@Model({ tableName: 'user_table' })
export class UserModel {
  @PartitionKey()
  id: string;

  email: string;

  createdAt: number;
  updatedAt: number;

  scopes: ScopeEnum[];

  constructor() {
    this.createdAt = Date.now();
    this.updatedAt = this.createdAt;
  }
}
Enter fullscreen mode Exit fullscreen mode

We add a array of scopes to our Model. Instead of using a string for the scopes we define a Scope enum which allows us to restrict the scopes to only specified scopes. This helps us to assign the correct scopes to a user. E.g. via an API:

export enum ScopeEnum {
  PLANT_READ = 'plant:read',
  PLANT_WRITE = 'plant:write',
}

Enter fullscreen mode Exit fullscreen mode

Now that we have updated the model we would need to assign the new scopes to some of our users so that we will be able to check their scopes later. Normally you would provide this functionality via a mutation similar to a addPlant Mutation, and only users who have the user:write access would be allowed to access the mutation. However, as this is not significantly different than what we already did, we will skip this part and directly update the users in the database. In either way you need to do this one time by hand for one user, because if you restrict the user to a specific scope and no user has this scope already, you will be locked out via the api.

So we will do it just via the aws management console:

Adding Scopes to a User in AWS

This gives the user the right to make changes on a plant and to view the plant object.

Restricting Access Bases on Scopes

Now that we have granted the user the access rights we need to make sure the api checks those rights, first we will update the authChecker to read the rights from the database:

export const customAuthChecker: AuthChecker<Context> = async (
  { context },
  roles
) => {
  if (!context.authToken) {
    throw new GraphQLError('No valid Token', {
      extensions: { code: 'FORBIDDEN' },
    });
  }
  await checkTokenAndUpdateUserId(context);

  const userService = Container.get<UserRepository>(UserRepository);
  console.log(context);
  const user = await userService.getUser(context.userId);

  context.scopes = user.scopes ?? [];

  // if the @Authorized() defines no roles
  if (roles.length === 0) {
    return true;
  }
  console.log('Check roles: ', roles);

  if (context.scopes.length === 0) {
    throw new GraphQLError('User lacks required role', {
      extensions: { code: 'FORBIDDEN' },
    });
  }

  // check roles if the @Authorized('ROLE') defines a role(s)
  if (context.scopes.some((role) => roles.includes(role))) {
    return true;
  }
  throw new GraphQLError('User lacks required role', {
    extensions: { code: 'FORBIDDEN' },
  });
};
Enter fullscreen mode Exit fullscreen mode

Just updating our auth-checker like this we will see no changed behavior. To see the effect of this update we need to change the @Authorized decorator on our mutation:

  @Authorized(['plant:write'])
  @Mutation(() => PlantType, {
    description: 'Add a new plant to the library.',
  })
  async addPlant(
    @Arg('name', () => String) name: string,
    @Arg('description', () => String) description: string
  ): Promise<PlantType> {
    const plant = new PlantModel();
    plant.name = name;
    plant.description = description;

    return this.plantRepository.createOrUpdatePlant(plant);
  }
Enter fullscreen mode Exit fullscreen mode

The new auth-checker needs access to read the data from the user table, so we need to add the following right in the serverless.stack.ts:

    userTable.grantReadWriteData(lambda);
Enter fullscreen mode Exit fullscreen mode

Now we can deploy the change again with nx build serverless-api && nx deploy serverless-cdk --profile serverless-hero

Afterwards we can check again in our playground, if we log in with a user without a scope we will receive an Unauthorized Error. If we log in with a user with the plant:write scope we will be able to execute the addPlant mutation.

Conclusion

In today's installment of our 'From Zero to Serverless Hero' series, we've further deepened our understanding of AWS Cognito and the intricate role of scopes within our serverless infrastructure. We've examined the way scopes are attached to an OAuth 2.0 token, acting as the gatekeepers of API operations that our applications need to perform.

However, we've also identified a critical point: In AWS Cognito, scopes are not user-specific. They are connected to an application client or a resource server, granting the same permissions to all users of a particular application client. For more granular, user-specific permissions, we have learned that the solution must be baked into our own application or API logic.

This realization has paved the way for us to explore custom scopes, setting the stage for us to build a system that can effectively manage user-specific permissions and deliver a tailored, secure experience. It's an exciting step forward on our journey to becoming serverless security heroes, as it introduces a new layer of depth to our understanding and capability.

As we wrap up this post, remember that the journey to mastering serverless security is akin to piecing together a complex puzzle. Every piece of information adds a new dimension, and every step we take gets us closer to seeing the whole picture.

Hey there, dear readers! Just a quick heads-up: we're code whisperers, not Shakespearean poets, so we've enlisted the help of a snazzy AI buddy to jazz up our written word a bit. Don't fret, the information is top-notch, but if any phrases seem to twinkle with literary brilliance, credit our bot. Remember, behind every great blog post is a sleep-deprived developer and their trusty AI sidekick.

Top comments (0)