Google Login on AWS Cognito Without Hosted UI (Work-around)
Kelvin Mwinuka
Posted on April 23, 2022
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:
We need to verify the
idToken
by using thegoogle-auth-library
package. After verifying the token, we get a "payload" that contains some useful information about the user and about the token.We need to check that the token issuer is
accounts.google.com
orhttps://accounts.google.com
and that the audience matches our Google client id.Next, we register the user with the
SignUpCommand
. We use a substring of the email as the username and thesub
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.
Posted on April 23, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.