Secure Your Next.js App: Advanced User Management with AWS Cognito Groups

The SaaS Enthusiast
8 min readFeb 16, 2024

--

Ensuring application security and efficient user access management is essential. This article builds upon a previously discussed method where a single attribute was used to identify admin users, a practical solution for basic scenarios outlined in our previous article. However, as we delve deeper into the complexities of application development, we recognize the need for a more scalable and secure approach. By transitioning to AWS Cognito Groups within AWS Amplify for managing user permissions, this article introduces a robust framework for advanced user access control in React and Next.js applications.

The Importance of Granular Access Control

Granular access control is crucial in today’s digital environment, where security breaches can have significant repercussions. By implementing AWS Cognito Groups, developers can ensure that users only access the features and data pertinent to their roles, thereby minimizing the risk of unauthorized access and data leaks. This level of control is especially important in applications that handle sensitive information or have complex user hierarchies, such as enterprise software, e-commerce platforms, and customer relationship management (CRM) systems.

Business Cases and Use Cases

Several business scenarios underscore the importance of this approach:

  • Enterprise Web Applications: In large organizations, users often have distinct roles that require access to specific parts of an application. For example, HR staff may need access to employee records, while finance teams might require access to billing and invoicing features.
  • E-Commerce Platforms: Different levels of access can be granted to administrators, vendors, and customers, enabling a customized interface and functionality for each group.
  • Educational Platforms: Teachers, students, and administrators could have different sets of permissions, with teachers accessing course creation tools, students viewing content, and administrators managing the entire platform.

Setting Up Cognito Users Groups in Serverless

You can define AWS Cognito User Groups as part of your infrastructure setup using the serverless.yml file when working with the Serverless Framework. This approach allows you to automate the deployment and configuration of your AWS resources, including Cognito User Pools and User Groups, ensuring a more consistent and reproducible infrastructure.

Here’s how you can define Cognito User Groups in the serverless.yml configuration:

1. Define the AWS Cognito User Pool

First, ensure you have a Cognito User Pool defined in your serverless.yml. If not, you need to add it under the resources section:

resources:
Resources:
CognitoUserPool:
Type: AWS::Cognito::UserPool
Properties:
# User Pool configuration
UserPoolName: MyUserPool
# Other properties like schemas, lambda triggers, etc.

2. Define User Groups within the User Pool

After defining the User Pool, you can then define User Groups within that pool. Add the User Group definitions under the same resources section:

AdminsGroup:
Type: AWS::Cognito::UserPoolGroup
Properties:
GroupName: admins
Description: "Admin group with special privileges"
UserPoolId:
Ref: CognitoUserPool
Precedence: 1 # Lower number takes higher precedence
LeadersGroup:
Type: AWS::Cognito::UserPoolGroup
Properties:
GroupName: leaders
Description: "Leaders group"
UserPoolId:
Ref: CognitoUserPool
Precedence: 2

Complete Example

Here is how the serverless.yml might look with both the User Pool and User Groups defined:

provider:
name: aws
runtime: nodejs12.x
region: us-east-1
resources:
Resources:
CognitoUserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: MyUserPool
# Other User Pool configurations
AdminsGroup:
Type: AWS::Cognito::UserPoolGroup
Properties:
GroupName: admins
Description: "Admin group with special privileges"
UserPoolId:
Ref: CognitoUserPool
Precedence: 1
LeadersGroup:
Type: AWS::Cognito::UserPoolGroup
Properties:
GroupName: leaders
Description: "Leaders group"
UserPoolId:
Ref: CognitoUserPool
Precedence: 2

Deployment

To deploy these resources, run the Serverless Framework deploy command from your terminal:

serverless deploy

This command will create the Cognito User Pool and the specified User Groups based on your serverless.yml configuration.

Using Higher Order Components For Routes Protection

Current State

We have discussed how to protect routes for authenticated users using Amplify, we created a UseContext to load information:

// UserContext.js
import React, {createContext, useContext, useEffect, useState} from 'react';
import {Auth, Hub} from "aws-amplify";

const UserContext = createContext(null);

export const useUser = () => useContext(UserContext);

export const UserProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [signedUser, setSignedUser] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(true);

// Logic to set and unset user, possibly from authentication service
const authListener = async () => {
Hub.listen('auth', async (data) => {
switch (data.payload.event) {
case 'signIn':
console.log('user signed in');
await getAuthenticatedUserData();
break;
case 'signOut':
console.log('user signed out');
removeUserInfo();
break;
}
});
}

useEffect(() => {
authListener();
}, []);

useEffect(() => {
async function getUser() {
try {
const isUser = await Auth.currentUserInfo();
if (isUser) {
// User is authenticated
getAuthenticatedUserData();
} else {
// User is not authenticated
removeUserInfo();
}
} catch (e) {
// Handle the case where no user is authenticated
removeUserInfo();
}
}

getUser();
}, []);

const getAuthenticatedUserData = async () => {
try {
const userMetadata = await Auth.currentAuthenticatedUser();
setUser(userMetadata);
setSignedUser(true);
setIsAuthenticating(false);
} catch (e) {
console.log(e);
}
};

const removeUserInfo = () => {
setUser(null);
setSignedUser(false);
setIsAuthenticating(false);
};

return (
<UserContext.Provider value={{ user, signedUser, isAuthenticating }}>
{children}
</UserContext.Provider>
);
};

export default UserContext;

We created a Hook we can use to wrap our routes with export default withAuth(Component)

import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useUser } from './UserContext';
import CedarLoading from "../components/CedarLoading";

const withAuth = (WrappedComponent) => {
return (props) => {
const { signedUser, isAuthenticating } = useUser();
const router = useRouter();

useEffect(() => {
if (!isAuthenticating) {
if (!signedUser && router.pathname !== '/login') {
router.push('/login');
}
}
}, [signedUser, isAuthenticating, router]);

// Optionally, render a loading spinner while checking auth status
if (isAuthenticating) {
return <CedarLoading /> ;
}

return signedUser ? <WrappedComponent {...props} /> : null;
};
};

export default withAuth;

Updating withAuth & UserContext

You’ll need to modify both your withAuth HOC and your useUser hook slightly to incorporate group checks. This involves fetching the user's groups from their session and deciding what content they're allowed to access based on their group memberships.

First, let’s enhance your useUser hook to include the user's groups. Amplify's Auth.currentAuthenticatedUser() method returns a user object that includes the JWT token, which contains the user's groups in the cognito:groups claim.

Here’s how you can update the getAuthenticatedUserData function to extract user groups:

// UserContext.js
// Add this function to decode the JWT token and extract groups
const getUserGroups = (user) => {
const token = user.signInUserSession.idToken.jwtToken;
const decoded = jwtDecode(token); // You'll need jwt-decode library for this
return decoded['cognito:groups'] || [];
};

const getAuthenticatedUserData = async () => {
try {
const userMetadata = await Auth.currentAuthenticatedUser();
const groups = getUserGroups(userMetadata);
setUser({ ...userMetadata, groups }); // Store user info including groups
setSignedUser(true);
setIsAuthenticating(false);
} catch (e) {
console.log(e);
}
};

You need to import jwtDecode from jwt-decode at the top of your UserContext.js:

import jwtDecode from 'jwt-decode'; // Make sure to npm install jwt-decode if you haven't already

Updating the withAuth HOC to Check for Groups

Next, modify your withAuth HOC to include a check for the user's group. You can pass a list of allowed groups as a parameter to the HOC and then check if the signed-in user belongs to any of these groups:

// withAuth.js
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useUser } from './UserContext';
import CedarLoading from "../components/CedarLoading";

const withAuth = (WrappedComponent, allowedGroups = []) => { // Add allowedGroups parameter
return (props) => {
const { user, isAuthenticating } = useUser();
const router = useRouter();

useEffect(() => {
if (!isAuthenticating) {
// Check both if the user is signed in and if they belong to an allowed group (if any groups are specified)
if (!user || (allowedGroups.length > 0 && !allowedGroups.some(group => user.groups.includes(group)))) {
router.push('/login');
}
}
}, [user, isAuthenticating, router]);

if (isAuthenticating) {
return <CedarLoading />;
}

return user ? <WrappedComponent {...props} /> : null;
};
};

export default withAuth;

With these changes, your withAuth HOC now supports restricting access based on user groups. When you use this HOC to wrap your components, you can specify which groups are allowed to access them:

import withAuth from '../path/to/withAuth';

const AdminPage = () => {
return <div>Admin-only content here</div>;
};

// Specify that only users in the 'admins' group can access this component
export default withAuth(AdminPage, ['admins']);

This setup allows you to leverage AWS Amplify and Cognito Groups for managing access control in your React/Next.js application more effectively.

Looking Toward the Future

The gap in direct AD group mapping to Cognito User Groups presents an opportunity for future development. As cloud services continue to evolve, finding seamless ways to integrate traditional enterprise identity management systems with modern cloud-native authentication services will be crucial. This integration could simplify user management, enhance security, and provide a more cohesive experience across enterprise applications. Moreover, as businesses continue to migrate to the cloud, the demand for such features will likely increase, making it an important area for developers, cloud service providers, and enterprises to focus on.

In conclusion, leveraging AWS Cognito Groups within React and Next.js applications through AWS Amplify offers a sophisticated method for managing user access and enhancing application security. While the current setup may not directly address all aspects of enterprise identity management, such as AD group mapping, it lays the groundwork for a more secure, efficient, and customizable access control framework that can adapt to the diverse needs of modern web applications. As technology progresses, the integration of cloud-native services with traditional identity management systems will undoubtedly become a key focus, paving the way for more secure and manageable web ecosystems.

Empower Your Tech Journey:

Explore a wealth of knowledge designed to elevate your tech projects and understanding. From safeguarding your applications to mastering serverless architecture, discover articles that resonate with your ambition.

New Projects or Consultancy

For new project collaborations or bespoke consultancy services, reach out directly and let’s transform your ideas into reality. Ready to take your project to the next level?

Protecting Routes

Advanced Serverless Techniques

Mastering Serverless Series

--

--