Google Authentication

Google Developer Console Setup

Google Project and Application Setup

The technique we will use for testing is to use the Google OAuth 2.0 Playground to create a refresh token that can be exchanged for an access token and id token during the testing phase.

First, a Google project is required. If you don't already have a project, you can create one using the Google Cloud Console. More information is available in the Google Cloud APIs Getting Started.

Next, use the Google API Console to create credentials for your web application. In the top navigation, click Create Credentials and choose OAuth client ID.

On the Create OAuth client ID page, enter the following:

Once saved, note the client ID and client secret. You can find these under the "OAuth 2.0 Client IDs" on the Google API Credentials page.

Using the Google OAuth 2.0 Playground to Create Testing Credentials

Note the client id and client secret from the previous step and visit the Google OAuth 2.0 Playground.

Click the gear icon in the upper right corner to reveal a OAuth 2.0 configuration panel. In this panel set the follow:

  • OAuth flow: Server-side
  • Access type: Offline
  • Check Use your own OAuth credentials.
  • OAuth Client ID: Your Google Application Client ID
  • OAuth Client secret: Your Google Application Client Secret

Select the Google APIs needed for your application under Step 1 (Select & authorize APIs), including the https://www.googleapis.com/auth/userinfo.profile endpoint under Google OAuth2 API v2 at a minimum. Click Authorize APIs.

Next, sign in with Google credentials to your test Google user account.

You will be redirected back to the Google OAuth 2.0 Playground under Step 2 (Exchange authorization code for tokens). Click the Exchange authorization code for token button.

You will be taken to Step 3 (Configure request to API). Note the returned refresh token to be used with testing.

Setting Google app credentials in Cypress

To have access to test user credentials within our tests we need to configure Cypress to use the Google environment variables set in .env inside of the cypress/plugins/index.js file.

// .env
REACT_APP_GOOGLE_CLIENTID = 'your-client-id'
REACT_APP_GOOGLE_CLIENT_SECRET = 'your-client-secret'
GOOGLE_REFRESH_TOKEN = 'your-refresh-token'
// cypress/plugins/index.js
// initial imports ...

dotenv.config()

export default (on, config) => {
  // ...
  config.env.googleRefreshToken = process.env.GOOGLE_REFRESH_TOKEN
  config.env.googleClientId = process.env.REACT_APP_GOOGLE_CLIENTID
  config.env.googleClientSecret = process.env.REACT_APP_GOOGLE_CLIENT_SECRET

  // plugins code ...

  return config
}

Custom Command for Google Authentication

Next, we will write a command named loginByGoogleApi to perform a programmatic login into Google and set an item in localStorage with the authenticated users details, which we will use in our application code to verify we are authenticated under test.

The loginByGoogleApi command will execute the following steps:

  1. Use the refresh token from the Google OAuth 2.0 Playground to perform the programmatic login, exchanging the refresh token for an access_token.
  2. Use the access_token returned to get the Google User profile.
  3. Finally the googleCypress localStorage item is set with the access token and user profile.
// cypress/support/commands.js
Cypress.Commands.add('loginByGoogleApi', () => {
  cy.log('Logging in to Google')
  cy.request({
    method: 'POST',
    url: 'https://www.googleapis.com/oauth2/v4/token',
    body: {
      grant_type: 'refresh_token',
      client_id: Cypress.env('googleClientId'),
      client_secret: Cypress.env('googleClientSecret'),
      refresh_token: Cypress.env('googleRefreshToken'),
    },
  }).then(({ body }) => {
    const { access_token, id_token } = body

    cy.request({
      method: 'GET',
      url: 'https://www.googleapis.com/oauth2/v3/userinfo',
      headers: { Authorization: `Bearer ${access_token}` },
    }).then(({ body }) => {
      cy.log(body)
      const userItem = {
        token: id_token,
        user: {
          googleId: body.sub,
          email: body.email,
          givenName: body.given_name,
          familyName: body.family_name,
          imageUrl: body.picture,
        },
      }

      window.localStorage.setItem('googleCypress', JSON.stringify(userItem))
      cy.visit('/')
    })
  })
})

With our Google app setup properly, necessary environment variables in place, and our loginByGoogleApi command implemented, we will be able to authenticate with Google while our app is under test. Below is a test to login as a user via Google, complete the onboarding process and logout.

describe('Google', function () {
  beforeEach(function () {
    cy.task('db:seed')
    cy.loginByGoogleApi()
  })

  it('shows onboarding', function () {
    cy.contains('Get Started').should('be.visible')
  })
})

Adapting an Google App for Testing

The Cypress Real World App is used and provides configuration and runnable code for both the React SPA and the Express back end.

The front end uses the react-google-login component and the back end uses express-jwt to validate the JWT provided by Google.

Adapting the back end

In order to validate API requests from the frontend, we install express-jwt and jwks-rsa and configure validation for JWT's from Google.

// backend/helpers.ts
import jwt from 'express-jwt'
import jwksRsa from 'jwks-rsa'

dotenv.config()
const googleJwtConfig = {
  secret: jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: 'https://www.googleapis.com/oauth2/v3/certs',
  }),
  // Validate the audience and the issuer.
  audience: process.env.REACT_APP_GOOGLE_CLIENTID,
  issuer: 'accounts.google.com',
  algorithms: ['RS256'],
}

Next, we'll define an Express middleware function to be use in our routes to verify the Google JWT sent by the front end API requests as the Bearer token.

// backend/helpers.ts
// ...
export const checkJwt = jwt(googleJwtConfig).unless({ path: ['/testData/*'] })

Once this helper is defined, we can use globally to apply to all routes:

// backend/app.ts
// initial imports ...
import { checkJwt } from './helpers'

// ...
if (process.env.REACT_APP_GOOGLE) {
  app.use(checkJwt)
}
// routes ...

Adapting the front end

We need to update our front end React app to allow for authentication with Google. As mentioned above, the front end uses the react-google-login component to perform the login.

First, we create a AppGoogle.tsx container to render our application as it is authenticated with Google. The component is identical to the App.tsx component, but has the addition of a GoogleLogin component in place of the original Sign Up and Sign In components.

A useGoogleLogin hook is added to send a GOOGLE event with the user and token objects to work with the existing authentication layer (authMachine.ts).

// src/containers/AppGoogle.tsx
// initial imports ...
import { GoogleLogin, useGoogleLogin } from "react-google-login"
// ...
const AppGoogle= () => {
  // ...
  useGoogleLogin({
      clientId: process.env.REACT_APP_GOOGLE_CLIENTID!,
      onSuccess: (res) => {
      authService.send("GOOGLE", { user: res.profileObj, token: res.tokenId });
    },
    cookiePolicy: "single_host_origin",
    isSignedIn: true,
  });
  // ...
  const isLoggedIn =
    isAuthenticated &&
    (authState.matches("authorized") ||
      authState.matches("refreshing") ||
      authState.matches("updating"));
  return (
    <div className={classes.root}>
      // ...
      {authState.matches("unauthorized") && (
        <Container component="main" maxWidth="xs">
          <CssBaseline />
          <div className={classes.paper}>
            <GoogleLogin
              clientId={process.env.REACT_APP_GOOGLE_CLIENTID!}
              buttonText="Login"
              cookiePolicy={"single_host_origin"}
            />
          </div>
        </Container>
      )}
    </div>
  );
};
export default AppGoogle;

Next, we update our entry point (index.tsx) to conditionally load the AppGoogle component if we start the application with the REACT_APP_GOOGLE environment variable set to true.

// src/index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import { Router } from 'react-router-dom'
import { history } from './utils/historyUtils'
import App from './containers/App'
import AppGoogle from './containers/AppGoogle'
import { createMuiTheme, ThemeProvider } from '@material-ui/core'
const theme = createMuiTheme({
  palette: {
    secondary: {
      main: '#fff',
    },
  },
})
ReactDOM.render(
  <Router history={history}>
    <ThemeProvider theme={theme}>
      {process.env.REACT_APP_GOOGLE ? <AppGoogle /> : <App />}
    </ThemeProvider>
  </Router>,
  document.getElementById('root')
)