Front-End Web & Mobile

NEW: Real-time multi-group app with AWS Amplify GraphQL – Build a “Twitter Community” clone

In a recent industry survey, over 66.6% (up from 59.7% in 2019) of JavaScript developers have used real-time technologies. Multiplayer apps makes your app more delightful and drives more organic adoption through user collaboration.

With today’s launch, AWS Amplify enables developers to configure dynamic multi-group authorization for real-time subscriptions. This is part three of a series of real-time subscription feature enhancements and will primarily focus on real-time multi-group authorization. Check out the prior blog posts to learn more about the ability to support service-side filters for GraphQL subscriptions to optimize network traffic and real-time multi-user authorization for your app data.

AWS Amplify is the fastest and easiest way to build cloud-powered mobile and web apps on AWS. Amplify comprises a set of tools and services that enables frontend web and mobile developers to leverage the power of AWS services to build innovative and feature-rich applications. The AWS Amplify CLI is a command line toolchain that helps frontend developers create app backends in the cloud.

What we’ll build

To showcase these real-time multi-group experiences, we’re going to build a “Twitter Community” clone with the following features:

  • Users can create new “communities” (example: Guitar, Piano, Soccer)
  • Users can browse available communities and join them
  • Users can choose to post new tweets in limited to those communities members or publicly available
  • Users can receive real-time updates as new tweets are posted

Architecture diagram for real-time multi-group app

The application consists of three major pieces:

  1. Amplify Auth: Enables end users to sign up and sign in via their desired login mechanism (username, email, phone number, or social providers). Powered by Amazon Cognito user pool.
  2. Amplify API (GraphQL): Allows end users to fetch data, stored in Amazon DynamoDB, based on their joined groups. The GraphQL API is powered by AWS AppSync.
  3. Amplify API (REST): An API endpoint to allow end users to create new groups and join and leave them. The REST API is built with Amazon API Gateway and the logic is stored in a Lambda function.

Pre-requisite

  • Install the latest Amplify CLI; version 10.6.2 and above required.
    • Open terminal and run npm i -g @aws-amplify/cli
  • Amplify CLI is already configured
    • If you haven’t configured the Amplify CLI yet, follow this guide on our documentation page

Step 1: Clone the starter app

To get hit the ground running, clone this starter app with your Github account.

git clone https://github.com/renebrandel/twitter-community.git

This starter template includes:

  • Pre-configured auth resources to enable users to sign up and sign in using their email
  • A “PostConfirmation” trigger that automatically adds all users to a group called “Everyone” when they sign up to your app. We’ll go into more depth on this later. For now, just consider this a “catch-all” bucket for our users.
  • GraphQL API that allows users to CRUD a “Tweet” given the appropriate authorization rules
  • UI code to enable users to sign in and view their available groups

Change into the cloned project directory and install the required dependencies:

cd twitter-community
npm install

There are two primary dependencies that we are installing:

  • Amplify Library – enables your app to connect AWS backend resources (AppSync, Cognito, REST APIs)
  • Amplify UI Library – provides us beautiful React UI primitives and connected components (Authenticator) in our React app

Let’s initialize your Amplify app in your AWS account:

amplify init

You might be prompted for a “group” name. Type in “Everyone“. This specifies that all users are automatically joining the “Everyone” group after sign-up. We’ll go in depth on this, when we build group management capabilities into this app.

Deploy the backend of the backend with the Amplify CLI:

amplify push

While the backend is deploying let’s take a look at your GraphQL schema file, located in amplify/backend/api/twittercommunity/schema.graphql, which describes your API’s data model and authorization rules:

type Tweet @model @auth(rules: [
  { allow: owner, operations: [create, delete] },
  { allow: groups, groupsField: "community", operations: [read] }
]) {
  content: String!
  community: String!
}

Let’s review the authorization rules:

  • { allow: owner, operations: [create, delete] } is an “owner-based” authorization rule. This allows every tweet to be “owned” by a user. If you “own the tweet”, you’re allowed to create and delete them. “Updating a tweet” might be a premium feature we launch in the future 😉.
  • { allow: groups, groupsField: "community", operations: [read] } is a “dynamic group” authorization rule. The group name that is stored in the community field designates which group is allowed to read the tweet. If you to enable multiple groups simultaneously to have access to the same tweet, you can modify the community field to communities: [String]!.

Let’s start the local development server to see display the starter app:

npm start

You should be able to login to your app but the group membership experience isn’t functional yet.

Step 2: Add group management to app

Sequence diagram for user group management

Next, let’s add a REST API to manage our app’s group membership. Amplify provides a quick shortcut to add a REST API with these administrative capabilities for your authentication resource. Use the Amplify CLI to update your auth resources and select “Create or update Admin queries API”.

amplify update auth
What do you want to do?
  Apply default configuration with Social Provider (Federation)
  Walkthrough all the auth configurations
  Create or update Cognito user pool groups
❯ Create or update Admin queries API

The Amplify CLI will prompt you which group can access these administrative APIs and in our example it should be every member of the “Everyone” group.

? Do you want to restrict access to the admin queries API to a specific Group Yes
? Select the group to restrict access with: Enter a custom group
? Provide a group name: Everyone

This Cognito user pool is already set up with a “PostConfirmation” trigger that automatically adds all users to a group called “Everyone” when they sign up to your app. If you want to learn more about it, check out the other function in the amplify/backend/function folder. If you want to build this auto-group-add feature from scratch, make sure to select Add User to Group when you setup Amplify Auth with amplify add auth.

The Amplify CLI will now create a new REST API called AdminQueries with a range of administrative capabilities available out-of-the-box. For our app, we won’t need all of these. We only need the following:

  • Functionality Amplify CLI created for us:
    • Add user to a group: This facilitates the ability for a customer to “join” a group.
    • Remove user to a group: This facilitates the ability for a customer to “leave” a group.
    • List all available groups: This enables us to list all available groups in the apps.
    • List groups for a given user: This enables us to display all the groups that the user is already part of.
    • List users in a group: This gives us the ability to browse who is already in a given group. Think of this as the reverse of the previous capability.
  • Functionality we need to add:
    • Add and join a group: This allows users to create a new group and join them in one API call.

To add the “Add and join a group” functionality and to remove any unused auto-generated functionalities, replace the app.js file and the cognitoActions.js file in amplifybackend/function/AdminQueries/src with the following contents:

app.js file:

const express = require('express');
const bodyParser = require('body-parser');
const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware');

const {
  addUserToGroup,
  removeUserFromGroup,
  addAndJoinGroup,
  listGroups,
  listGroupsForUser,
  listUsersInGroup,
} = require('./cognitoActions');

const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(awsServerlessExpressMiddleware.eventContext());

// Enable CORS for all methods
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  next();
});

// Only perform tasks if the user is in a specific group
const allowedGroup = process.env.GROUP;

const checkGroup = function (req, res, next) {
  if (req.path == '/signUserOut') {
    return next();
  }

  if (typeof allowedGroup === 'undefined' || allowedGroup === 'NONE') {
    return next();
  }

  // Fail if group enforcement is being used
  if (req.apiGateway.event.requestContext.authorizer.claims['cognito:groups']) {
    const groups = req.apiGateway.event.requestContext.authorizer.claims['cognito:groups'].split(',');
    if (!(allowedGroup && groups.indexOf(allowedGroup) > -1)) {
      const err = new Error(`User does not have permissions to perform administrative tasks`);
      next(err);
    }
  } else {
    const err = new Error(`User does not have permissions to perform administrative tasks`);
    err.statusCode = 403;
    next(err);
  }
  next();
};

app.all('*', checkGroup);

app.post('/addMeToGroup', async (req, res, next) => {
  if (!req.body.groupname) {
    const err = new Error('Groupname is required');
    err.statusCode = 400;
    return next(err);
  }

  try {
    const username = req.apiGateway.event.requestContext.authorizer.claims.username
    const response = await addUserToGroup(username, req.body.groupname);
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

app.post('/removeMeFromGroup', async (req, res, next) => {
  if (!req.body.groupname) {
    const err = new Error('Groupname is required');
    err.statusCode = 400;
    return next(err);
  }

  try {
    const username = req.apiGateway.event.requestContext.authorizer.claims.username
    const response = await removeUserFromGroup(username, req.body.groupname);
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

app.post('/addGroupAndJoinMe', async (req, res, next) => {
  if (!req.body.groupname) {
    const err = new Error('Groupname is required');
    err.statusCode = 400;
    return next(err);
  }

  try {
    const username = req.apiGateway.event.requestContext.authorizer.claims.username
    const response = await addAndJoinGroup(username, req.body.groupname);
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
})

app.get('/listGroups', async (req, res, next) => {
  try {
    let response;
    if (req.query.token) {
      response = await listGroups(req.query.limit || 25, req.query.token);
    } else if (req.query.limit) {
      response = await listGroups((Limit = req.query.limit));
    } else {
      response = await listGroups();
    }
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

app.get('/listGroupsForMe', async (req, res, next) => {
  try {
    const username = req.apiGateway.event.requestContext.authorizer.claims.username

    let response;
    if (req.query.token) {
      response = await listGroupsForUser(username, req.query.limit || 25, req.query.token);
    } else if (req.query.limit) {
      response = await listGroupsForUser(username, (Limit = req.query.limit));
    } else {
      response = await listGroupsForUser(username);
    }
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

app.get('/listUsersInGroup', async (req, res, next) => {
  if (!req.query.groupname) {
    const err = new Error('groupname is required');
    err.statusCode = 400;
    return next(err);
  }

  try {
    let response;
    if (req.query.token) {
      response = await listUsersInGroup(req.query.groupname, req.query.limit || 25, req.query.token);
    } else if (req.query.limit) {
      response = await listUsersInGroup(req.query.groupname, (Limit = req.query.limit));
    } else {
      response = await listUsersInGroup(req.query.groupname);
    }
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

// Error middleware must be defined last
app.use((err, req, res, next) => {
  console.error(err.message);
  if (!err.statusCode) err.statusCode = 500; // If err has no specified error code, set error code to 'Internal Server Error (500)'
  res.status(err.statusCode).json({ message: err.message }).end();
});

app.listen(3000, () => {
  console.log('App started');
});

module.exports = app;

cogintoActions.js file:

const { CognitoIdentityServiceProvider } = require('aws-sdk');

const cognitoIdentityServiceProvider = new CognitoIdentityServiceProvider();
const userPoolId = process.env.USERPOOL;

async function addUserToGroup(username, groupname) {
  const params = {
    GroupName: groupname,
    UserPoolId: userPoolId,
    Username: username,
  };

  console.log(`Attempting to add ${username} to ${groupname}`);

  try {
    const result = await cognitoIdentityServiceProvider.adminAddUserToGroup(params).promise();
    console.log(`Success adding ${username} to ${groupname}`);
    return {
      message: `Success adding ${username} to ${groupname}`,
    };
  } catch (err) {
    console.log(err);
    throw err;
  }
}

async function removeUserFromGroup(username, groupname) {
  const params = {
    GroupName: groupname,
    UserPoolId: userPoolId,
    Username: username,
  };

  console.log(`Attempting to remove ${username} from ${groupname}`);

  try {
    const result = await cognitoIdentityServiceProvider.adminRemoveUserFromGroup(params).promise();
    console.log(`Removed ${username} from ${groupname}`);
    return {
      message: `Removed ${username} from ${groupname}`,
    };
  } catch (err) {
    console.log(err);
    throw err;
  }
}

async function addAndJoinGroup(username, groupname) {
  const params = {
    GroupName: groupname,
    UserPoolId: userPoolId,
  }
  /**
   * Check if the group exists; if it doesn't, create it.
   */
  try {
    await cognitoIdentityServiceProvider.getGroup(params).promise();
  } catch (e) {
    await cognitoIdentityServiceProvider.createGroup(params).promise();
  }

  try {
    await addUserToGroup(username, groupname)
    return {
      message: `Success in creating ${groupname} and adding ${username}`,
    };
  } catch (err) {
    console.log(err);
    throw err;
  }
}

async function listGroups(Limit, PaginationToken) {
  const params = {
    UserPoolId: userPoolId,
    ...(Limit && { Limit }),
    ...(PaginationToken && { PaginationToken }),
  };

  console.log('Attempting to list groups');

  try {
    const result = await cognitoIdentityServiceProvider.listGroups(params).promise();

    // Rename to NextToken for consistency with other Cognito APIs
    result.NextToken = result.PaginationToken;
    delete result.PaginationToken;

    return result;
  } catch (err) {
    console.log(err);
    throw err;
  }
}

async function listGroupsForUser(username, Limit, NextToken) {
  const params = {
    UserPoolId: userPoolId,
    Username: username,
    ...(Limit && { Limit }),
    ...(NextToken && { NextToken }),
  };

  console.log(`Attempting to list groups for ${username}`);

  try {
    const result = await cognitoIdentityServiceProvider.adminListGroupsForUser(params).promise();
    /**
     * We are filtering out the results that seem to be innapropriate for client applications
     * to prevent any informaiton disclosure. Customers can modify if they have the need.
     */
    result.Groups.forEach(val => {
      delete val.UserPoolId, delete val.LastModifiedDate, delete val.CreationDate, delete val.Precedence, delete val.RoleArn;
    });

    return result;
  } catch (err) {
    console.log(err);
    throw err;
  }
}

async function listUsersInGroup(groupname, Limit, NextToken) {
  const params = {
    GroupName: groupname,
    UserPoolId: userPoolId,
    ...(Limit && { Limit }),
    ...(NextToken && { NextToken }),
  };

  console.log(`Attempting to list users in group ${groupname}`);

  try {
    const result = await cognitoIdentityServiceProvider.listUsersInGroup(params).promise();
    return result;
  } catch (err) {
    console.log(err);
    throw err;
  }
}

module.exports = {
  addUserToGroup,
  removeUserFromGroup,
  addAndJoinGroup,
  listGroups,
  listGroupsForUser,
  listUsersInGroup,
};

Because we also leverage additional permission in addition to the ones that are provided by default, we also need to update the amplify/backend/function/AdminQueriesXYZ/AdminQueriesXYZ-cloudformation-template.json file and append the following IAM permissions "cognito-idp:GetGroup", "cognito-idp:CreateGroup" under the “PolicyDocument” section right below cognito-idp:ListUsersInGroup:

The PolicyDocument for the Cognito user pool access should look something like this:

"Action": [
    "cognito-idp:ListUsersInGroup",
    "cognito-idp:GetGroup", // <-- new permission to get a specific group's info
    "cognito-idp:CreateGroup", // <-- new permission to create a new group
    "cognito-idp:AdminUserGlobalSignOut",
    "cognito-idp:AdminEnableUser",
    "cognito-idp:AdminDisableUser",
    "cognito-idp:AdminRemoveUserFromGroup",
    "cognito-idp:AdminAddUserToGroup",
    "cognito-idp:AdminListGroupsForUser",
    "cognito-idp:AdminGetUser",
    "cognito-idp:AdminConfirmSignUp",
    "cognito-idp:ListUsers",
    "cognito-idp:ListGroups"
],

With these files updated, we’ve effectively created six new REST API routes to enable users to manage their group membership. Next, let’s update the client code to call these new API routes. In our client code src/ path, you can find a utils.js file that connects to these REST APIs.

Deploy your backend API changes by running the following command in your Terminal:

amplify push

The components/Groups.js file enables customers to create new groups (see handleCreateGroupClick function) and manage their membership (see handleGroupClick function). After every invocation to the administrative API, we refresh the list of available groups and propagate them throughout our app via the CommunityContext in the App.js component.

To implement the group membership workflows, replace the handler functions in components/GroupsPanel.js with the following code:

Animation showing the ability to join and leave groups
  async function handleCreateGroupClick() {
    await createAndJoinGroup(window.prompt('Group Name?'))
    refreshGroups()
  }

  async function handleGroupClick(groupName, joined) {
    if (joined) {
      await leaveGroup(groupName)
    } else {
      await joinGroup(groupName)
    }
    refreshGroups();
  }

Important: You need to invalidate the current user session token whenever we make a change to a user’s group membership. This is because the GraphQL API will retrieve a user’s group membership information from the client-side provided session information. You can see we call invalidateRefreshToken after every mutation to a user’s group membership in the src/util.js file.

Step 3: Use multi-group real-time subscriptions

Let’s now build the real-time update functionality. Whenever the user creates a new tweet in a group, every other member using the app, should see the tweet in real-time.

In the PostPanel.js, you can see us fetching the list of tweets in the useEffect which runs every time the user information, including their group membership, changes.

  useEffect(() => {
    fetchTweets();

    const sub = API.graphql({
      query: onCreateTweet
    }).subscribe({
      next: ({ value }) => {
        console.log(value)
        setTweets(tweets => [value.data.onCreateTweet, ...tweets])
      }
    });

    return () => { sub.unsubscribe() }
  }, [user]);

And that’s it! Try it out and see for yourself how you can subscribe to different groups’ tweets and see them in real-time!

Demo of multi-group real-time subscription working

Known limitations

  •  If you authorize based on a single group per record, then subscriptions are only supported if the user is part of 5 or fewer user groups
  • If you authorize via an array of groups (communities: [String]! example above),
    • subscriptions are only supported if the user is part of 20 or less user groups
    • you can only authorize 20 or less user groups per record
  • Be aware of the Cognito user pool quotas, such as “groups per user pool” and “groups to which each user can belong”

🥳 Success

In this blog post, you’ve learned how to manage user group membership and filter user data dynamically based on the assigned group membership.

Run amplify delete from your Terminal to tear down your Amplify backend and clean-up the generated resources, including DynamoDB table, AppSync GraphQL API, and Amazon Cognito resources.

Here are some other cool features you’ll want to check out:

As always, feel free to reach out to the Amplify team via GitHub or join our Discord community. Follow @AWSAmplify on Twitter to get the latest updates on feature launches, DX enhancements, and other announcements.