Front-End Web & Mobile

Implement group based authorization for AWS AppSync GraphQL APIs with Okta

This article was written by Deepti Chilmakuru, Cloud Application Architect, AWS, and Jack Michel, Front End Developer, AWS


September 14, 2021: Amazon Elasticsearch Service has been renamed to Amazon OpenSearch Service. See details.

AWS AppSync is a managed serverless GraphQL service that simplifies application development by letting you create a flexible API to securely access, manipulate, and combine data from one or more data sources with a single network call. With AppSync, developers can build scalable applications on a range of data sources, including Amazon DynamoDB NoSQL tables, Amazon Aurora Serverless relational databases, Amazon OpenSearch Service (successor to Amazon Elasticsearch Service) clusters, HTTP/REST APIs, and serverless functions powered by AWS Lambda.

There are five modes you can use to authorize applications interacting with your data from a GraphQL API in AppSync:

  • API_KEY, to authorize clients based on API keys.
  • AWS_IAM, to authorize clients based on AWS Identity and Access Management (IAM) policies and roles.
  • OPENID_CONNECT, to authorize clients from OpenID Connect IdPs (identity providers) such as Okta or Auth0.
  • AMAZON_COGNITO_USER_POOLS, to authorize clients based on Amazon Cognito User Pools.
  • AWS_LAMBDA, the latest recently released mode to authorize clients based on custom business logic in an AWS Lambda serverless function.

The authorization modes above provide flexibility to cover all sorts of authorization use cases and scenarios for your APIs in AppSync. What if you have a requirement to implement granular logic to allow access to users based on their specific group membership defined in an external OpenID Connect identity provider? You may want to authorize a user in an allowed group and deny other users if they are not members. In this article we go over an approach that analyses group membership based on user attributes, also called claims, in a JSON Web Token (JWT) to achieve our API authorization goal.

Here is how it works at a high level :

  • Configure Okta as an Identity Provider for GraphQL APIs with AWS AppSync.
  • Create a default group in Okta with minimal privileges. The group will be used to assign all new user accounts with limited basic access. Privileged users are added to other team-specific groups with additional permissions.
  • AWS AppSync then de-constructs ID tokens validated from Okta to identify claims. Okta’s group information is made available as a claim in the JWT token which is retrieved after authentication by the client
  • The request is sent to an AWS Lambda data source for further authorization checks and additional business logic. While these authorization checks could be done natively in AppSync using built-in resolver mapping templates based on VTL (Apache Velocity Template Language), we choose to use Lambda as it allows us to use our programming language of choice.

  1. The client authenticates with Okta and receives a JWT which is used to authorize requests in AppSync.
  2. The client makes a request to AWS AppSync, passing the JWT as the authorization token.
  3. AppSync validates the authorization token.
  4. AppSync forwards the request to the Lambda data source. The identity information from the token is passed to the Lambda as identity context on the event.
  5. Business logic in the Lambda function retrieves the user’s group information from the event identity context, and determines if the client should be allowed to perform the requested action. The action is performed if the client is authorized to do so, otherwise an unauthorized exception is returned to the client.

Authentication

This design leverages OpenID Connect to authorize clients with Okta as the identity provider (IdP) for all our users. Okta is a third party authentication service that provides a suite of identity controls for developers to integrate into their platforms. It enables administrators to manage user accounts as well as apply security policies independent of the protected service, which includes strong password requirements, and multi-factor authentication. Administrators also have the ability to invite users to create new accounts through Okta.

User Registration and Account Creation (Sign up)

Users can access our application with the available Okta SSO tooling. Authorized administrators can request an account to be created on behalf of an external partner or vendor. Upon creation, Okta operators work with administrators to assign newly created user accounts to their appropriate Okta groups, which are used to restrict user access to API backend resources.

Authorization

Once a user has been successfully authenticated, the application must determine what services and resources are authorized for a given user to access. Since privileges are expected to vary across users, the application must be able to determine the correct set of privileges for an authenticated user.

The Application will authorize users based on the Okta group membership. By allowing administrators to establish groups, users can be assigned Okta groups based on their job family, team, level, and expected application role. Users can also be assigned to multiple user groups.

A user’s group information is made available as a claim in the ID token which is retrieved after authentication by the client. The group claim, along with others, can be accessed to provide context to the application. This can be used to tailor the user experience. The example below depicts the ID token for a user with its claims:

{
  "sub": "00u11vpxxdFcRU9JO5d7",
  "name": "JANE DOE",
  "locale": "en-US",
  "email": "janedoe@amazon.com",
  "ver": 1,
  "iss": "https://abcdefgh.okta.com",
  "aud": "0oa1cc9xxohktbTKk5d7",
  "iat": 1627409047,
  "exp": 1627412647,
  "jti": "ID.ZHThIh-HlSU8gcsEpa9nOilt91lmNV-Ud7qciLQy82E",
  "amr": [
    "pwd"
  ],
  "idp": "00o22vpa4xiTehBCe5d7",
  "nonce": "1234",
  "preferred_username": "janedoe@amazon.com",
  "given_name": "Jane",
  "family_name": "Doe",
  "zoneinfo": "America/Los_Angeles",
  "updated_at": 1627404110,
  "email_verified": true,
  "auth_time": 1627404763,
  "groups": [ "admin", "Everyone" ]
}

Implementation

1. Okta Setup

  1. Follow the steps from Okta setup section of our blog to setup an Okta application as an OIDC identity provider.
  2. Once you’ve completed the above steps, follow this guide to add the groups claim to the Okta ID token.
  3. Create an Okta group by clicking Add Group in Groups under the Directory option on the side menu. Provide a group name such as admin and then assign users by clicking the Manage People button. Click Save to implement the changes.

2. Create the AppSync API

In the AWS AppSync console create a new API by clicking Create API. Select Build from scratch, then click Start. Give your API a name, for example, “List Posts App”. After the API is created, select Schema on the left menu under the API name, and use the following GraphQL schema. Click Save Schema.

type Post {
  id: String!
  content: String!
}

type Query {
  listPosts: [Post]!
  listDraftPosts: [Post]!
}

After the schema is saved, go to Settings, then set the Default authorization mode to Open ID Connect. Under Configuration, enter your Okta domain name in the OpenID Connect provider domain (URL) and your Okta client ID under Client ID. Click Save when ready.

3. Create a new Data Source 

Data sources are resources in your AWS account that GraphQL APIs can interact with to create, retrieve or update data. AppSync supports different data source types such as AWS Lambda, Amazon DynamoDB, relational databases (Amazon Aurora Serverless), Amazon OpenSearch Service, and HTTP endpoints as data sources. We use a direct Lambda resolver as our data source.

In order to create a direct Lambda resolver function, navigate to the Lambda console and click Create Function. Select Author from scratch and use appsync-orchestrator as function name. Select Node.js 14.x as the runtime option and click Create Function.

Once the Lambda function is created, you can edit the file index.js in the console IDE and add the sample code below.

The Lambda function examines the authenticated user’s groups based on the JWT claims in the ID token and returns either the requested data or an error based on the group membership check. The map is hard coded in this function for demonstration purposes. In production, depending on the number of groups you have, you could use an Amazon DynamoDB table to store all the Okta groups with related permissions.

const parseUserIdentity = (userIdentity) => {
  const oktaGroups = userIdentity.claims['groups'];

  const { email } = userIdentity.claims;

  return {
    oktaGroups,
    email,
  };
};

const oktaGroupActionMapping = {
    admin: ['listPosts', 'listDraftPosts'],
    guest: ['listPosts']
};

const getAllowedActions = (userAttributes) => {
  const allowedActions = userAttributes.oktaGroups
    .flatMap(oktaGroup => oktaGroupActionMapping[oktaGroup] ?? []);

  return new Set(allowedActions);
};

class UnauthorizedException extends Error {
    constructor(message) {
        super(message);
        this.name = "UnauthorizedException";
    }
}

class BadRequest extends Error {
    constructor(message) {
        super(message);
        this.name = "BadRequest";
    }
}

exports.handler = async(event) => {
  console.log('Received Event: ', JSON.stringify(event, null, 2));
  const userAttributes = parseUserIdentity(event.identity);
  const allowedActions = getAllowedActions(userAttributes);
  const { fieldName } = event.info;

  switch (fieldName) {
    case 'listPosts': {
      if (allowedActions.has('listPosts')) {
        // We are returning sample data, in practice this is where you 
        // would want to make a request to your business logic Lambda function
        // ie) return getPostsFromBusinessLogicLambda();
        return [{id: 'post1', content: "My sample post"}];
      }
      
      throw new UnauthorizedException();
    }
              
    case 'listDraftPosts': {
      if (allowedActions.has('listDraftPosts')) {
        // We are returning sample data, in practice this is where you 
        // would want to make a request to your business logic Lambda function
        // ie) return getDraftPostsFromBusinessLogicLambda();
        return [{id: 'post2', content: "A work in progress"}];
      }
      
      throw new UnauthorizedException();
    }
    
    default:
      throw new BadRequest();
  }
};

Next, navigate back to the AppSync console. Select Data Sources under your API name. Click Create data source. Here you create a data source pointing to the appsync-orchestrator Lambda function we just deployed. Click Create.

4. Wire the Schema

Now we have done all the prep work, it’s time to wire the lambda function to the GraphQL schema we defined earlier. Select Schema on the left menu under the API name. In the Resolvers section on the right side, for both listPosts and listDraftPosts queries, select Attach to attach the LambdaDataSource created earlier to both queries. When AppSync receives the caller’s request, it executes the Lambda code to resolve the fields defined in the schema.

In the Create New Resolver screen for both queries, select the LambdaDataSource from the drop-down with the default options then click Save Resolver.

Testing

Once you have an Access Token from Okta, you can test your AppSync API:

1. Use the following URL to get a JWT token from Okta:

https://<ACCOUNTID>.okta.com/oauth2/v1/authorize?client_id=<CLIENT_ID>&response_type=id_token&nonce=1234&scope=openid%20profile%20groups%20email&state=test&redirect_uri=https://YOUR DOMAIN/

2. After logging in successfully, Okta will redirect to the redirect_url, for example:

https://www.YOURDOMAIN.com/#id_token=<ID_TOKEN …..> &state=test

3. Copy the ID_TOKEN value from the URL.

4. On the AppSync console, select Queries from the left menu and paste the token from Okta in the Authorization Token field next to the orange button used to execute queries.

5. The fields from the Posts and DraftPosts types can be fetched using GraphQL queries. Select the listPosts and listDraftPosts queries and click the execute query button to fetch the data as shown in the previous step.

6. If an Okta user is only assigned to the guest Okta group, then the following unauthorized error is thrown on execution of the listDraftPosts query as the guest user only has permissions to execute listPosts and not listDraftPosts.

Conclusion

In this post we showed how to to implement a simple group-based authorization logic for OpenID Connect users accessing GraphQL APIs in AppSync. We also demonstrated how quickly and easily you can create APIs by using AppSync with a direct Lambda resolver as an AppSync data source. With this solution, you now have a fully managed, highly available serverless GraphQL backend that uses group-based authorization to determine what data a given user has access to.

About the authors

Deepti Chilmakuru is a Cloud Application Architect at AWS, focusing on Application migrations and Serverless platforms. She engages with customers to create innovative solutions that address customer business problems and accelerate the adoption of AWS services. In her spare time, Deepti enjoys spending time with her family, reading, listening to music and traveling.

Jack is a Front End Developer with AWS ProServe. Jack is excited about the trend towards serverless, and is passionate about technologies that enable engineers to build at higher levels of abstraction—closer to the needs of their customers. When he’s not at his computer, you’ll find Jack outside biking, hiking, disc golfing or playing soccer.