Google Login on AWS Cognito Without Hosted UI (Work-around)

Google Login on AWS Cognito Without Hosted UI (Work-around)

I've previously written an article about basic username/password authentication with AWS Cognito. This article will cover registration and authentication using Google.

Cognito offers this functionality built into the hosted-ui. However, if you find the hosted UI to be limited in terms of design or functionality, you might want to implement this authentication method using the AWS SDK in your own custom UI.

Unfortunately, there is no straightforward way of doing this, so this is a possible workaround to that particular limitation.

If you haven't read the other articles in this series, I'm using a react frontend with a nodejs backend for this tutorial. My framework of choice is NextJS. You don't have to use this exact framework. As long as you use a JS frontend and a NodeJS backend, you should be able to adapt this tutorial to your own stack.

Setup

First, we need to install a couple of packages to help us out in our implementation. The 2 packages we need are react-google-login to facilitate Google login in the frontend, and google-auth-library for verifying Google tokens in the backend.

Install the packages with the following command in the terminal: npm install react-google-login google-auth-library.

Registration

On the registration page, import GoogleLogin from react-google-login and add the button to the layout.

You can find more information about this package and how to customise the button here.

<GoogleLogin
    clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID}
    responseType={'id_token'}
    buttonText="Register with Google"
    onSuccess={googleRegisterSuccess}
    onFailure={googleRegisterFailure}
/>

We pass the googleRegisterSuccess function to the onSuccess prop. We have a useRegister hook that contains the googleRegisterSuccess function.

Now, define the googleRegisterSuccess function in the useRegister hook as follows:

const googleRegisterSuccess = (googleResponse) => {
        fetch('/api/register/google', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({ id_token: googleResponse?.tokenId })
        }).then(res => {
            if (!res.ok) throw res
            router.push({
                pathname: '/login'
            })
        }).catch(err => {
            console.error(err)
        })
    }

const googleRegisterFailure = (googleResponse) => {
    console.error(googleResponse)
}

All we're interested in from the response object is the tokenId, which we send to the backend for verification.

In the handler for the Google register route, we have the following code:

import { CognitoIdentityProviderClient, SignUpCommand } from '@aws-sdk/client-cognito-identity-provider'
import { OAuth2Client } from 'google-auth-library'

const {
  COGNITO_REGION,
  COGNITO_APP_CLIENT_ID,
  GOOGLE_TOKEN_ISSUER,
  NEXT_PUBLIC_GOOGLE_CLIENT_ID,
} = process.env

export default async function handler(req, res) {
  if (req.method !== 'POST') return res.status(405).send()

  let googlePayload

  try {
    // Verify the id token from google
    const oauthClient = new OAuth2Client(NEXT_PUBLIC_GOOGLE_CLIENT_ID)
    const ticket = await oauthClient.verifyIdToken({
      idToken: req.body.id_token,
      audience: NEXT_PUBLIC_GOOGLE_CLIENT_ID
    })
    googlePayload = ticket.getPayload()
    if (
      !googlePayload?.iss === GOOGLE_TOKEN_ISSUER ||
      !googlePayload?.aud === NEXT_PUBLIC_GOOGLE_CLIENT_ID
    ) {
      throw new Error("Token issuer or audience invalid.")
    }
  } catch (err) {
    return res.status(422).json({ message: err.toString() })
  }

  // Register the user
  try {
    const params = {
      ClientId: COGNITO_APP_CLIENT_ID,
      Username: googlePayload.email.split("@")[0], // Username extracted from email address
      Password: googlePayload.sub,
      UserAttributes: [
        {
          Name: 'email',
          Value: googlePayload.email
        },
        {
          Name: 'custom:RegistrationMethod',
          Value: 'google'
        }
      ],
      ClientMetadata: {
        'EmailVerified': googlePayload.email_verified.toString()
      }
    }
    const cognitoClient = new CognitoIdentityProviderClient({
      region: COGNITO_REGION
    })
    const signUpCommand = new SignUpCommand(params)
    const response = await cognitoClient.send(signUpCommand)
    return res.status(response['$metadata'].httpStatusCode).send()
  } catch (err) {
    console.log(err)
    return res.status(err['$metadata'].httpStatusCode).json({ message: err.toString() })
  }
}

Let's go through what's happening here:

  1. We need to verify the idToken by using the google-auth-library package. After verifying the token, we get a "payload" that contains some useful information about the user and about the token.

  2. We need to check that the token issuer is accounts.google.com or https://accounts.google.com and that the audience matches our Google client id.

  3. Next, we register the user with the SignUpCommand. We use a substring of the email as the username and the sub as the password. You'll want to be careful, you may need to find a more creative way to set the password.

Just make sure you can reproduce the string that you pass here otherwise the user will not be able to sign in later. Here, I've used sub because it does not change. You can hash this, add a prefix/suffix, or both if you want to go the extra mile.

In ClientMetadata, we specify the verification status for this email address. We will be using this in the pre-signup trigger later on.

Optionally, you can set a custom attribute custom:RegistrationMethod in case you have several registration methods for your user pool.

PreSignup Trigger

In a previous article on cognito pre-signup triggers, we added some custom logic upon signup that checks if the provided email address is already in use. Feel free to check that article out for more details on that.

We are going to add some logic to that pre-signup trigger to cater to users who register using Google.

module.exports.handler = async (event, context, callback) => {
  // Check if the provided email address is already in use

  // Verify user's email address if it's already verified with Google.
  if (event.request.userAttributes['custom:RegistrationMethod'] === "google") {
    let userEmailVerified = event.request.clientMetadata['EmailVerified'] === 'true'
    event.response.autoVerifyEmail = userEmailVerified
    event.response.autoConfirmUser = userEmailVerified
  }

  callback(null, event);
};

If the user's email address is already verified by Google, we don't need to verify it ourselves. So we set autoVerifyEmail to whatever value we set in the EmailVerified attribute of the ClientMetadata.

When you set autoVerifyEmail to true, you also have to do the same for autoConfirmUser as you cannot verify an email address without confirming the user in cognito.

You can find the code for the cognito triggers I use in this series in this Github repo.

Sign in

Now, that we've handled registration, let's handle signing in. On your login page, add the GoogleLogin button just like we did in the registration page.

<GoogleLogin 
    clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID}
    buttonText="Login with Google"
    responseType={'id_token'}
    onSuccess={googleSignInSuccess}
    onFailure={googleSignInFailure}
/>

The useAuth hook contains a googleSignInSuccess function that sends the tokenId from Google to the Google login API endpoint on the backend.

const googleSignInSuccess = (googleResponse) => {
    fetch('/api/login/google', {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ id_token: googleResponse?.tokenId })
    }).then(res => {
      if (!res.ok) throw res
      return res.json()
    }).then(data => {
      console.log(data)
    }).catch(err => {
      console.error(err)
    })
  }

  const googleSignInFailure = (googleResponse) => {
    console.error(googleResponse)
  }

In the backend, we handle the login as follows:

import { CognitoIdentityProviderClient, AdminInitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider";
import { OAuth2Client } from "google-auth-library";

const {
  COGNITO_REGION,
  COGNITO_APP_CLIENT_ID,
  COGNITO_USER_POOL_ID,
  GOOGLE_TOKEN_ISSUER,
  NEXT_PUBLIC_GOOGLE_CLIENT_ID
} = process.env

export default async function handler(req, res){
  if (!req.method === 'POST') return res.status(405).send()

  let googlePayload

  try {
    const oauthClient = new OAuth2Client(NEXT_PUBLIC_GOOGLE_CLIENT_ID)
    const ticket = await oauthClient.verifyIdToken({
      idToken: req.body.id_token,
      audience: NEXT_PUBLIC_GOOGLE_CLIENT_ID
    })
    googlePayload = ticket.getPayload()
    if (
      !googlePayload?.iss === GOOGLE_TOKEN_ISSUER ||
      !googlePayload?.aud === NEXT_PUBLIC_GOOGLE_CLIENT_ID
    ) {
      throw new Error("Token issuer or audience invalid.")
    }
  } catch (err) {
    return res.status(422).json({ message: err.toString() })
  }

  // Sign the user in
  try {
    const params = {
      AuthFlow: 'ADMIN_USER_PASSWORD_AUTH',
      ClientId: COGNITO_APP_CLIENT_ID,
      UserPoolId: COGNITO_USER_POOL_ID,
      AuthParameters: {
        USERNAME: googlePayload?.email,
        PASSWORD: googlePayload?.sub
      }
    }
    const cognitoClient = new CognitoIdentityProviderClient({
      region: COGNITO_REGION
    })
    const adminInitiateAuthCommand = new AdminInitiateAuthCommand(params)
    const response = await cognitoClient.send(adminInitiateAuthCommand)
    return res.status(response['$metadata'].httpStatusCode).json({
      ...response.AuthenticationResult
    })
  } catch (err) {
    console.log(err)
    return res.status(err['$metadata'].httpStatusCode).json({ message: err.toString() })
  }
}

The first part of the handler is quite similar to the registration: we verify the token and grab the user's profile details along with more information about the token.

We check the token's issuer and audience to make sure they are correct, and then we proceed to the sign in.

For signing in, we use AdminInitiateAuthCommand along with the ADMIN_USER_PASSWORD_AUTH authentication flow in the params.

AdminInitiateAuthCommand is the recommended way to handle auth in a secure backend environment as it requires developer credentials, adding an extra layer of security.

Pass the username in the params and then generate the password the same way you did in the registration handler. This is why it's important to be able to reproduce the password you created in the registration.

Although we're logging in with Google on the surface, we're still using username/password "under the hood".

Conclusion

That's it on this workaround on Google login with AWS Cognito without going through the hosted UI. This is not an ideal solution, but it serves the purpose if you want to quickly implement simple Google signup/sign-in in your app.

You can check out this repository for the code I reference in this series.