Okta Authentication

End-to-End Only

Okta Developer Console Setup

If not already setup, you will need to create an Okta application within the Okta Developer Console. Once the Okta application is created, the Okta Developer Console will provide a Client ID, which will used alongside your Okta domain to configure Okta SDKs as shown in the subsequent sections of this guide.

Setting Okta app credentials in Cypress

To have access to test user credentials within our tests we need to configure Cypress to use the Okta environment variables set in the .env file.

const { defineConfig } = require('cypress')

// Populate process.env with values from .env file
require('dotenv').config()

module.exports = defineConfig({
  env: {
    auth_username: process.env.AUTH_USERNAME,
    auth_password: process.env.AUTH_PASSWORD,
    okta_domain: process.env.REACT_APP_OKTA_DOMAIN,
    okta_client_id: process.env.REACT_APP_OKTA_CLIENTID
  }
})
import { defineConfig } from 'cypress'

// Populate process.env with values from .env file
require('dotenv').config()

export default defineConfig({
  env: {
    auth_username: process.env.AUTH_USERNAME,
    auth_password: process.env.AUTH_PASSWORD,
    okta_domain: process.env.REACT_APP_OKTA_DOMAIN,
    okta_client_id: process.env.REACT_APP_OKTA_CLIENTID
  }
})
// cypress/plugins/index.js

// Populate process.env with values from .env file
require('dotenv').config()

module.exports = (on, config) => {
  config.env.auth_username = process.env.AUTH_USERNAME
  config.env.auth_password = process.env.AUTH_PASSWORD
  config.env.okta_domain = process.env.REACT_APP_OKTA_DOMAIN
  config.env.okta_client_id = process.env.REACT_APP_OKTA_CLIENTID
  return config
}

Custom Command for Okta Authentication

There are two ways you can authenticate to Okta:

Login with cy.origin()

Next, we'll write a custom command called loginByOkta to perform a login to Okta. This command will use cy.origin() to

  1. navigate to the Okta origin
  2. input user credentials
  3. sign in and redirect back to the Cypress Real World App
  4. cache the results with cy.session()
// cypress/support/auth-provider-commands/okta.ts
// Okta
const loginToOkta = (username: string, password: string) => {
  Cypress.log({
    displayName: 'OKTA LOGIN',
    message: [`🔐 Authenticating | ${username}`],
    autoEnd: false,
  })

  cy.visit('/')
  cy.origin(
    Cypress.env('okta_domain'),
    { args: { username, password } },
    ({ username, password }) => {
      cy.get('input[name="identifier"]').type(username)
      cy.get('input[name="credentials.passcode"]').type(password, {
        log: false,
      })
      cy.get('[type="submit"]').click()
    }
  )

  cy.get('[data-test="sidenav-username"]').should('contain', username)
}
// right now our custom command is light. More on this later!
Cypress.Commands.add('loginByOkta', (username: string, password: string) => {
  return loginToOkta(username, password)
})

Now, we can use our loginByOkta command in the test. Below is our test to login as a user via Okta and run a few basic sanity checks.

describe('Okta', function () {
  beforeEach(function () {
    cy.task('db:seed')
    cy.loginByOkta(Cypress.env('okta_username'), Cypress.env('okta_password'))
  })

  it('verifies signed in user does not have a bank account', function () {
    cy.get('[data-test="sidenav-bankaccounts"]').click()
    cy.get('[data-test="empty-list-header"]').should('be.visible')
  })
})

Lastly, we can refactor our login command to take advantage of cy.session() to store our logged in user so we don't have to reauthenticate with everything test.

Cypress.Commands.add('loginByOkta', (username: string, password: string) => {
  cy.session(
    `okta-${username}`,
    () => {
      return loginToOkta(username, password)
    },
    {
      validate() {
        cy.visit('/')
        cy.get('[data-test="sidenav-username"]').should('contain', username)
      },
    }
  )
})

Programmatic Login

Next, we will write a command named loginByOktaApi to perform a programmatic login into Okta 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.

In order to make sure this is enabled inside the Cypress Real World App, Please set the REACT_APP_OKTA_PROGRAMMATIC environment variable to true.

The loginByOktaApi command will execute the following steps:

  1. Use the Okta Authentication API to perform the programmatic login.
  2. Then uses an instance of OktaAuth client from the Okta Auth SDK to gain the id_token once a session token is obtained.
  3. Finally the oktaCypress localStorage item is set with the access token and user profile.
// cypress/support/commands.js
import { OktaAuth } from '@okta/okta-auth-js'

// Okta
Cypress.Commands.add('loginByOktaApi', (username, password) => {
  cy.request({
    method: 'POST',
    url: `https://${Cypress.env('okta_domain')}/api/v1/authn`,
    body: {
      username,
      password,
    },
  }).then(({ body }) => {
    const user = body._embedded.user
    const config = {
      issuer: `https://${Cypress.env('okta_domain')}/oauth2/default`,
      clientId: Cypress.env('okta_client_id'),
      redirectUri: 'http://localhost:3000/implicit/callback',
      scope: ['openid', 'email', 'profile'],
    }

    const authClient = new OktaAuth(config)

    return authClient.token
      .getWithoutPrompt({ sessionToken: body.sessionToken })
      .then(({ tokens }) => {
        const userItem = {
          token: tokens.accessToken.value,
          user: {
            sub: user.id,
            email: user.profile.login,
            given_name: user.profile.firstName,
            family_name: user.profile.lastName,
            preferred_username: user.profile.login,
          },
        }

        window.localStorage.setItem('oktaCypress', JSON.stringify(userItem))

        log.snapshot('after')
        log.end()
      })
  })
})

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

describe('Okta', function () {
  beforeEach(function () {
    cy.task('db:seed')
    cy.loginByOktaApi(
      Cypress.env('auth_username'),
      Cypress.env('auth_password')
    )
  })

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

Adapting an Okta 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 Okta React SDK for React Single Page Applications (SPA), which uses the Okta Auth SDK underneath. The back end uses the Okta JWT Verifier for Node.js to validate JWTs from Okta.

Adapting the back end

In order to validate API requests from the frontend, we install Okta JWT Verifier for Node.js and configure it using the Okta Domain and Client ID provided after Creating an Okta application.

// backend/helpers.ts
import OktaJwtVerifier from '@okta/jwt-verifier'

dotenv.config()

// Okta Validate the JWT Signature
const oktaJwtVerifier = new OktaJwtVerifier({
  issuer: `https://${process.env.REACT_APP_OKTA_DOMAIN}/oauth2/default`,
  clientId: process.env.REACT_APP_OKTA_CLIENTID,
  assertClaims: {
    aud: 'api://default',
    cid: process.env.REACT_APP_OKTA_CLIENTID,
  },
})

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

// backend/helpers.ts

// ...

export const verifyOktaToken = (req, res, next) => {
  const bearerHeader = req.headers['authorization']

  if (bearerHeader) {
    const bearer = bearerHeader.split(' ')
    const bearerToken = bearer[1]

    oktaJwtVerifier
      .verifyAccessToken(bearerToken, 'api://default')
      .then((jwt) => {
        // the token is valid
        req.user = {
          // @ts-ignore
          sub: jwt.sub,
        }

        return next()
      })
      .catch((err) => {
        // a validation failed, inspect the error
        console.log('error', err)
      })
  } else {
    res.status(401).send({
      error: 'Unauthorized',
    })
  }
}

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

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

// ...

if (process.env.REACT_APP_OKTA) {
  app.use(verifyOktaToken)
}

// routes ...

Adapting the front end

We need to update our front end React app to allow for authentication with Okta using the Okta React SDK.

First, we create a AppOkta.tsx container, based off of the App.tsx component.

AppOkta.tsx uses the useOktaAuth React Hook, replaces the Sign Up and Sign In routes with a SecureRoute and LoginCallback and wraps the component with the withOktaAuth higher order component (HOC).

A useEffect hook is added to get the access token for the authenticated user and send an OKTA event with the user and token objects to work with the existing authentication layer (authMachine.ts). We define a route for implicit/callback to render the LoginCallback component and a SecureRoute for the root path.

// src/containers/AppOkta.tsx
// initial imports ...
import {
  LoginCallback,
  SecureRoute,
  useOktaAuth,
  withOktaAuth,
} from '@okta/okta-react'

// ...

const AppOkta: React.FC = () => {
  const { authState, oktaAuth } = useOktaAuth()

  // ...

  useEffect(() => {
    if (authState.isAuthenticated) {
      oktaAuth.getUser().then((user) => {
        authService.send('OKTA', { user, token: oktaAuthState.accessToken })
      })
    }
  }, [authState, oktaAuth])

  // ...

  return (
    <div className={classes.root}>
      // ...
      {authState.matches('unauthorized') && (
        <>
          <Route path="/implicit/callback" component={LoginCallback} />
          <SecureRoute exact path="/" />
        </>
      )}
      // ...
    </div>
  )
}

export default withOktaAuth(AppOkta)

Next, we update our entry point (index.tsx) to wrap our application with the <Security> component from the Okta React SDK providing issuer, clientId from our Okta application, along with a redirectUri as props using the REACT_APP_OKTA variables are defined in our .env.

// src/index.tsx

// initial imports ...
import { OktaAuth } from '@okta/okta-auth-js'
import { Security } from '@okta/okta-react'
import AppOkta from './containers/AppOkta'

// ...

const oktaAuth = new OktaAuth({
  issuer: `https://${process.env.REACT_APP_OKTA_DOMAIN}/oauth2/default`,
  clientId: process.env.REACT_APP_OKTA_CLIENTID,
  redirectUri: window.location.origin + '/implicit/callback',
})

ReactDOM.render(
  <Router history={history}>
    <ThemeProvider theme={theme}>
      {process.env.REACT_APP_OKTA ? (
        <Security oktaAuth={oktaAuth}>
          <AppOkta />
        </Security>
      ) : (
        <App />
      )}
    </ThemeProvider>
  </Router>,
  document.getElementById('root')
)

An update to our AppOkta.tsx component is needed to conditionally use the oktaCypress localStorage item.

In the code below, we conditionally apply a useEffect block based on being under test with Cypress (using window.Cypress).

In addition, we will update the export to be wrapped with the withOktaAuth higher order component only if we are not under test in Cypress. This allows our application to work with the Okta redirect login flow in development/production but not when under test in Cypress.

// src/containers/AppOkta.tsx
// initial imports ...
import { LoginCallback, SecureRoute, useOktaAuth, withOktaAuth } from "@okta/okta-react";

// ...

const AppOkta: React.FC = () => {
  const { authState, oktaAuth } = useOktaAuth();

  // ...

  // If under test in Cypress, get credentials from "oktaCypress" localstorage item and send event to our state management to log the user into the SPA
  if (window.Cypress) {
    useEffect(() => {
      const okta = JSON.parse(localStorage.getItem("oktaCypress")!);
      authService.send("OKTA", {
        user: okta.user,
        token: okta.token,
      });
    }, []);
  } else {
    useEffect(() => {
      if (authState.isAuthenticated) {
        oktaAuth.getUser().then((user) => {
          authService.send("OKTA", { user, token: oktaAuthState.accessToken });
        });
      }
    }, [authState, oktaAuth]);
  }

  // ...

  return (
    <div className={classes.root}>

      // ...

      {authState.matches("unauthorized") && (
        <>
          <Route path="/implicit/callback" component={LoginCallback} />
          <SecureRoute exact path="/" />
        </>
      )}

      // ...
    </div>
  );
};

// Conditional export wrapped with `withOktaAuth` if we are not under test in Cypress
let appOkta = window.Cypress ? AppOkta : withOktaAuth(AppOkta);
export default appOkta;