Creating Truly Custom UI Components for AWS Cognito – Amplify in React Native
Niek de Wit
Posted on August 14, 2024
Creating Truly Custom UI Components for AWS Cognito – Amplify in React Native
AWS Cognito is a powerful tool within the AWS ecosystem, but it’s one of the most under-documented services, particularly when it comes to building custom UI components. If you’ve tried to navigate the existing documentation or blogs, you’ve likely found them confusing or incomplete, especially when you need full control over the authentication flow and user interface.
The Problem: Lack of Documentation for Custom Cognito Components
When it comes to implementing Cognito UI in React Native, the two most common approaches you’ll find are:
These options work well for standard use cases, but what if your application requires complete customization of the authentication process and UI design? Unfortunately, detailed documentation on this topic is scarce.
After spending considerable time figuring this out, I’ve developed an effective method for creating custom React Native components for Cognito. This post aims to provide a clear and practical guide to doing just that, offering an alternative to the default aws-amplify components or hosted UI.
Architecture Overview: Amplify vs. CDK
In my case, I had already set up most of my AWS infrastructure using the AWS Cloud Development Kit (CDK). During my research, I discovered that Amplify also offers tools for defining infrastructure resources, adding another layer of complexity. The challenge was finding a way to seamlessly integrate Cognito with CDK while utilizing Amplify’s frontend components/SDK—a topic that is not well-covered in existing documentation.
In this blog, I’ll walk you through setting up Cognito using CDK, connecting it to your frontend, and creating custom UI components using the Amplify SDK.
CDK Implementation: Understanding User Pools and Identity Pools
One of the most challenging concepts to grasp when working with Cognito is the difference between User Pools and Identity Pools. The official documentation says:
“User pools are for authentication. Your app users can sign in through the user pool, or federate through a third-party identity provider (IdP). Identity pools are for authorization. You can use identity pools to create unique identities for users, and give them access to other AWS services.”
While technically correct, this explanation can be a bit abstract. Here’s a simpler way to think about it:
- User Pool: A database for users.
- Identity Pool: A way to attach authorization policies, which can apply to one or more User Pools.
For an initial proof of concept, you’ll likely only need a User Pool. Alongside this, you’ll also need to define a User Pool Client, which acts as an endpoint accessible by the frontend. The User Pool Client has settings like whether authentication via username and password is allowed. You can create multiple User Pool Clients with different configurations, all connected to the same User Pool, depending on your use case.
Here’s how I initialize the authentication constructs in our CDK stack:
export class Authentication extends Construct {
userPool: UserPool;
constructor(scope: Construct, id: string, props: AuthenticationProps) {
super(scope, id);
this.userPool = new UserPool(this, `${props.prefix}-UserPool`, {
selfSignUpEnabled: true,
signInAliases: {
email: true,
username: false,
},
standardAttributes: {
email: {
required: true,
},
},
passwordPolicy: {
minLength: 8,
},
accountRecovery: AccountRecovery.EMAIL_ONLY,
removalPolicy: RemovalPolicy.DESTROY,
mfa: Mfa.OPTIONAL,
});
const userPoolClient = new UserPoolClient(this, `${props.prefix}-UserPoolClient`, {
userPool: this.userPool,
authFlows: {
userPassword: true,
userSrp: true,
},
generateSecret: false,
oAuth: {
flows: {
authorizationCodeGrant: true,
},
scopes: [OAuthScope.EMAIL, OAuthScope.OPENID, OAuthScope.PROFILE],
callbackUrls: ['http://localhost:3000'],
},
});
new StringParameter(this, `${props.prefix}-CognitoConfig`, {
parameterName: `/${props.prefix}/cognito-config`,
description: 'Cognito configuration',
stringValue: JSON.stringify({
userPoolId: this.userPool.userPoolId,
userPoolClientId: userPoolClient.userPoolClientId,
}),
});
}
}
The specific configuration parameters are beyond the scope of this blog post, but you can find detailed information about them online. In the code above, we export the userPoolId and userPoolClientId to the Parameter Store for easy reference during development. This also allows us to automatically load these parameters into environment variables during the CI build process.
Configuring the Frontend
First, make the userPoolId
and userPoolClientId
available in your frontend. I chose to add the configuration as an environment variable, which allows us to dynamically insert this configuration during build time in CI.
AWS_COGNITO_CONFIG={"userPoolId":"eu-north-1_abc123","userPoolClientId":"abc123"}
The following code is the core of this post: an authentication service used in our frontend components. While it’s still a work in progress (e.g., it lacks proper error handling), it provides a solid starting point for building an authentication service for Cognito.
We use the aws-amplify package to initialize Amplify and combine it with functions from the @aws-amplify/auth package to interact with the Amplify SDK.
import {
AuthSession,
CodeDeliveryDetails,
fetchAuthSession,
signIn,
confirmSignUp,
resendSignUpCode,
confirmResetPassword,
signOut,
signUp,
resetPassword,
} from '@aws-amplify/auth';
import { signal } from '@preact/signals-react';
import { Amplify } from 'aws-amplify';
import { router } from 'expo-router';
const cognitoConfig = JSON.parse(String(process.env.AWS_COGNITO_CONFIG)) as {
userPoolId: string;
userPoolClientId: string;
};
export enum AuthStatus {
SIGNED_IN,
SIGNED_OUT,
CONFIRM_SIGNUP,
CONFIRM_RESET,
LOADING,
}
export class AuthenticationService {
$authStatus = signal(AuthStatus.LOADING);
$authSession = signal<AuthSession | null>(null);
$codeDeliveryDetails = signal<CodeDeliveryDetails | null>(null);
private confirmUsername?: string;
constructor() {
Amplify.configure({
Auth: {
Cognito: {
userPoolId: cognitoConfig.userPoolId,
userPoolClientId: cognitoConfig.userPoolClientId,
},
},
});
this.refreshAuthState();
}
private async refreshAuthState() {
this.$authStatus.value = AuthStatus.LOADING;
const currentAuthSession = await fetchAuthSession();
this.$authStatus.value = currentAuthSession.userSub ? AuthStatus.SIGNED_IN : AuthStatus.SIGNED_OUT;
this.$authSession.value = currentAuthSession;
}
public async signIn(username: string, password: string) {
if (this.$authStatus.value === AuthStatus.SIGNED_IN) {
console.log('Already logged in!');
return;
}
this.$authStatus.value = AuthStatus.LOADING;
try {
const signInResult = await signIn({
username,
password,
});
if (signInResult.isSignedIn === true) {
await this.refreshAuthState();
return;
}
if (signInResult.nextStep.signInStep === 'CONFIRM_SIGN_UP') {
this.confirmUsername = username;
this.resendConfirmSignupCode();
router.replace('/auth/sign_up/verify');
}
} catch (e) {
console.error(e);
//Todo error handling/messaging
await this.refreshAuthState();
}
}
public async signUp(username: string, password: string) {
if (this.$authStatus.value === AuthStatus.SIGNED_IN) {
console.log('Already logged in!');
return;
}
this.$authStatus.value = AuthStatus.LOADING;
try {
const signUpResult = await signUp({
username,
password,
});
if (signUpResult.isSignUpComplete === true) {
await this.refreshAuthState();
return;
}
if (signUpResult.nextStep.signUpStep === 'CONFIRM_SIGN_UP') {
this.$codeDeliveryDetails.value = signUpResult.nextStep.codeDeliveryDetails;
this.$authStatus.value = AuthStatus.CONFIRM_SIGNUP;
this.confirmUsername = username;
router.replace('/auth/sign_up/verify');
}
} catch (e) {
console.error(e);
//Todo error handling/messaging
await this.refreshAuthState();
}
}
public async signOut() {
this.$authStatus.value = AuthStatus.LOADING;
await signOut();
await this.refreshAuthState();
}
public async verifySignUp(confirmationCode: string) {
if (this.confirmUsername) {
try {
const confirmResult = await confirmSignUp({
username: this.confirmUsername,
confirmationCode,
});
this.confirmUsername = undefined;
if (confirmResult.isSignUpComplete) {
router.replace('/auth/sign_in');
}
} catch (e) {
console.error(e);
//Todo error handling/messaging
//Dont refreshAuthState here, since this will take you away from the verification screen
}
}
}
public async resendConfirmSignupCode() {
if (this.confirmUsername) {
const resendCodeResult = await resendSignUpCode({ username: this.confirmUsername });
this.$codeDeliveryDetails.value = resendCodeResult;
this.$authStatus.value = AuthStatus.CONFIRM_SIGNUP;
}
}
public async resetPassword(username: string) {
this.$authStatus.value = AuthStatus.LOADING;
try {
const resetPasswordResult = await resetPassword({ username });
if (resetPasswordResult.isPasswordReset === true) {
await this.refreshAuthState();
return;
}
if (resetPasswordResult.nextStep.resetPasswordStep === 'CONFIRM_RESET_PASSWORD_WITH_CODE') {
this.$codeDeliveryDetails.value = resetPasswordResult.nextStep.codeDeliveryDetails;
this.$authStatus.value = AuthStatus.CONFIRM_RESET;
this.confirmUsername = username;
router.replace('/auth/sign_in/forgot_password/verify');
}
} catch (e) {
console.error(e);
//Todo error handling/messaging
}
}
public async verifyPasswordReset(confirmationCode: string, newPassword: string) {
if (this.confirmUsername) {
try {
await confirmResetPassword({
username: this.confirmUsername,
newPassword,
confirmationCode,
});
this.confirmUsername = undefined;
router.replace('/auth/sign_in');
} catch (e) {
console.error(e);
//Todo error handling/messaging
//Dont refreshAuthState here, since this will take you away from the verification screen
}
}
}
}
While the code may look lengthy, the concept is straightforward: we're essentially wrapping the SDK methods to expose the authentication state using Preact Signals, allowing the frontend to react accordingly. Feel free to adapt this code to suit your needs. Although Preact is specific to React, you could easily replace it with another solution to create a similar service in different architectures.
Implementing Frontend Authorization Guards
The remaining part of this blog isn't specific to Cognito/Amplify, but it might still be useful. Here’s how you could use the custom service to implement an Authorization Route Guard, which redirects users if they don't meet certain authorization/authentication checks.
import { useGlobalContext } from '@/services/global-context';
import { AuthenticationService, AuthStatus } from '@auth/authentication.service';
import { useComputed, useSignalEffect } from '@preact/signals-react';
import { router } from 'expo-router';
import { ReactElement, ReactNode } from 'react';
import { View } from 'react-native';
const AUTH_GUARD_LOADING_NAME = 'AuthorizationGuardLoading';
const AUTH_GUARD_PASSED_NAME = 'AuthorizationGuardPassed';
/** @useSignals */
const AuthorizationGuardLayoutComponent = (props: {
guard: (auth: AuthenticationService) => string | null;
children: (ReactElement & { type: { id: string } })[];
}) => {
const authLoading = props.children.find((child) => child.type.id === AUTH_GUARD_LOADING_NAME);
const authPassed = props.children.find((child) => child.type.id === AUTH_GUARD_PASSED_NAME);
const globalContext = useGlobalContext();
const $content = useComputed(() => {
const authIsLoading = globalContext.authenticationService.$authStatus.value === AuthStatus.LOADING;
const authSession = globalContext.authenticationService.$authSession.value;
if (authIsLoading || authSession === null) {
return 'LOADING';
}
const guardResult = props.guard(globalContext.authenticationService);
if (guardResult === null) {
return 'PASSED';
}
return guardResult;
});
useSignalEffect(() => {
if ($content.value !== 'PASSED' && $content.value !== 'LOADING') {
router.replace($content.value);
}
});
return <View testID='authGuard'>{$content.value === 'PASSED' ? authPassed : authLoading}</View>;
};
const AuthorizationGuardLoading = (props: { children: ReactNode }) => {
return <View testID='authGuardLoading'>{props.children}</View>;
};
AuthorizationGuardLoading.id = AUTH_GUARD_LOADING_NAME;
const AuthorizationGuardPassed = (props: { children: ReactNode }) => {
return <View testID='authGuardPassed'>{props.children}</View>;
};
AuthorizationGuardPassed.id = AUTH_GUARD_PASSED_NAME;
This Guard component leverages the Authentication service from the global context to determine the current authentication status. If the authorization check fails, it redirects the user using the React Native Expo router; otherwise, it displays the relevant content, all managed dynamically using Preact Signals.
Using this guard might look like this;
import {
authCheckIsLoggedOut,
AuthorizationGuardLayoutComponent,
AuthorizationGuardLoading,
AuthorizationGuardPassed,
} from '@/components/layouts/route_guards/authorization_guard.layout.component';
import SignInFeatureComponent from '@features/sign_in/sign_in.feature.component';
import React from 'react';
export const SignInRouteComponent = () => {
return (
<AuthorizationGuardLayoutComponent guard={(auth) => (authCheckIsLoggedOut(auth) ? null : 'home')}>
<AuthorizationGuardLoading>
<div>checking sign in auth..</div>
</AuthorizationGuardLoading>
<AuthorizationGuardPassed>
<SignInFeatureComponent />
</AuthorizationGuardPassed>
</AuthorizationGuardLayoutComponent>
);
};
export default SignInRouteComponent;
Integrating the Guard
Here’s how you might integrate this guard into your feature components:
import { useGlobalContext } from '@/services/global-context';
import { Link } from 'expo-router';
import React, { useState } from 'react';
import { Button, TextInput } from 'react-native';
export const SignInFeatureComponent = () => {
const [email, onChangeEmail] = useState<string>('');
const [password, onChangePassword] = useState<string>('');
const globalContext = useGlobalContext();
const onPressSignIn = () => {
globalContext.authenticationService.signIn(email, password);
};
return (
<>
<div>username</div>
<TextInput
testID='emailInput'
onChangeText={onChangeEmail}
textContentType='username'
value={email}
/>
<div>password</div>
<TextInput
testID='passwordInput'
onChangeText={onChangePassword}
textContentType='password'
value={password}
secureTextEntry={true}
/>
<div>
<Link href='/auth/sign_in/forgot_password'>Forgot your password?</Link>
</div>
<Button
testID='signInButton'
onPress={onPressSignIn}
title='Sign In'
/>
<div>
<div>Need an account?</div>
<Link
testID='needAnAccount'
href='/auth/sign_up'
>
Sign up
</Link>
</div>
</>
);
};
export default SignInFeatureComponent;
In this component, you might notice the absence of a redirect after user sign-in. This is handled automatically by the RouterGuard. For example, if the guard check for a page is defined as:
guard={(auth) => (authCheckIsLoggedOut(auth) ? null : 'home')}
The user is automatically redirected to home
as soon as authCheckIsLoggedOut(auth)
returns false
. This check is re-evaluated every time the authentication state changes, thanks to its implementation within a useComputed
Preact hook that uses the authentication state’s Preact Signals.
This post should provide a solid foundation for those looking to build truly custom UI components for AWS Cognito using Amplify in React Native. By following these steps, you’ll gain full control over both the authentication flow and the user experience, something that's not easily achievable with the out-of-the-box solutions.
Posted on August 14, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 14, 2024