Google Login on AWS Cognito Without Hosted UI (Work-around)
Tags: aws, google, authentication, webdev
Published: 2022-04-23 18:01:13

---
title: Google Login on AWS Cognito Without Hosted UI (Work-around)
published: true
date: 2022-04-23 17:50:54 UTC
tags: aws,google,authentication,webdevelopment
canonical_url: https://kelvinmwinuka.com/google-login-on-aws-cognito-without-hosted-ui-work-around
---

I've previously written an article about [basic username/password authentication with AWS Cognito](https://kelvinmwinuka.com/basic-authentication-with-aws-cognito-and-nextjs). 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](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/index.html) 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](https://www.npmjs.com/package/react-google-login).

```
<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:

```javascript
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:

```javascript
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](https://kelvinmwinuka.com/pre-signup-validation-on-aws-cognito), 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.

```javascript
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](https://github.com/kelvinmwinuka/cognito-triggers).

## 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.

```javascript
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:

```javascript
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](https://github.com/kelvinmwinuka/cognito-next) for the code I reference in this series.