AWS Cognito + Azure AD + React + Amplify

Introduction

Recently I’ve had to uplift a solution to integrate its authentication into Azure AD.

While there have been several great blog posts on how to configure AWS Cognito to use Azure AD as a SAML Provider what happens after that has been sparse pickings.

Here are the good blogs that cover off configuring Cognito with Azure AD as this blog post will not re-invent this wheel:

High Level Steps to Configure Azure AD as your SSO Provider of choice with an AWS Amplify React App using Cognito

The following are the end to end high level tasks you’ll need to undertake to get this working:

  1. Create your amplify / react application locally using the command lines / standard processes
  2. Use amplify to create a cognito user pool
  3. Setup your Enterprise Application in Azure AD
  4. Setup your Cognito User pool to use the Azure AD as its provider
  5. Setup your applications amplify authentication const correctly
  6. Setup Cognito to use the right Signin URL for SSO with Azure AD
  7. Setup a Button for Sign In if you haven’t come from Azure Portal.

Once you have done this then there will be two ways you can access your application.

  1. Via https://portal.microsoft.com as a tile provisioned by the Azure AD Enterprise Application
  2. Directly by hitting the applications URL

What happens differs between them both. I’m going to go over the reactions of the access models first. This should aid in your understanding later in this blog.

Via Microsoft Portal

The process to use Microsoft Portal to get to the application is as follows:

  1. Login to https://portal.microsoft.com with your Companys identity / login id.
  2. Find the Apps / All Apps section
  3. Locate the Application Tile
  4. Click it

What happens next is that Azure AD will use the settings in the Enterprise Application SAML SSO settings to work out what URLs are needed to be used to redirect you (the client) to the application.

Azure AD will do some verification around your capability to use the application in question (i.e. are you allowed to use it) and then create a SAML Token / Session that is then passed down to the WebApp.

The WebApp is redirected too with the SAML payload. The WebApp invokes Amplify and its sub method for checking Authentication. You will be seen as authenticated and the app will use the configuration in the aws-exports.js to know what URLs it should use for redirection.

Directly by hitting the applications URL

The process to use WebApps URL directly to get to the application is as follows:

  1. Open a browser
  2. Type the URL of the WebApp into the location bar
  3. Hit enter

What happens next is based on the following lines of code ( in the case of my baseline example code):

  render() {
    const { authState } = this.state;
 
    // main return routine
    return (
      <div className="App">
        {
          // if authState is null display loading message.
          authState === null && (<div>loading...</div>)
        }
        {
          // if authState is set to signIn then show the login page with the single button for O365 javascript redirect
          authState === 'signIn' && <OAuthButton/>
        }
        {
          // if authState is signedIn then we've got a logged in user - lets start our app up!
          // or rather lets just show a sign out button for now.. sigh.
          authState === 'signedIn' ? 
          (
            <div class='signout'>
              <button onClick={this.signOut}>Sign out of application {user_givenname} {user_email}</button>
            </div>
          ) : null
        }
      </div>
    );
  }

A constant is being set called authState from this.state:

const { authState } = this.state;

This has been created by the other methods in the class. If the authState is null show Loading.. If the authState is SignIn then you need to login so we display a login button. If the authState is SignedIn then you are logged on so we show the app, or in our case a sign out button with some extra info in it as a test.

Ok so how did authState get set?

Yes you are correct in thinking its the typical Amplify mechanisms in play as defined here: https://aws-amplify.github.io/docs/js/authentication

However the missing piece to the puzzle lies in three places:

  1. The “oauth”: key in the awsmobile constant in the aws-exports.js needs a jiggle
  2. The sign in URL in the Azure AD SSO settings need a tweak
  3. Login button uses a “Javascript redirect” to Azure, not the withOAuth() method that normally redirects to a Cognito Hosted UI. We’re avoiding the withOAuth() to get seamless sign on if you’re authenticated already.

awsmobile oauth changes required

Even though this says not to manually edit this file; you need too. As you are unlikely to modify the amplify add auth you did once it has been set up this addition should stay there and be somewhat safe.

One needs to add the client_id into the oauth declaration and the responseType must be Token. As follows:

//file: aws-exports.js

// WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten.

const awsmobile = {
    "aws_project_region": "ap-southeast-2",
    "aws_cognito_identity_pool_id": "ap-southeast-2:d177381b-d5b4-46b6-ae0b-12345678",
    "aws_cognito_region": "ap-southeast-2",
    "aws_user_pools_id": "ap-southeast-2_weoweo",
    "aws_user_pools_web_client_id": "iamaclientidnot",
    "oauth": {
        "domain": "mydomain-dev.auth.ap-southeast-2.amazoncognito.com",
        "scope": [
            "phone",
            "email",
            "openid",
            "profile",
            "aws.cognito.signin.user.admin"
        ],
        "redirectSignIn": "https://testapp.dunlop.geek.nz/",
        "redirectSignOut": "https://testapp.dunlop.geek.nz/",
        "responseType": "token",
        "client_id": "thecognitoclientidgoeshere"
    },
    "federationTarget": "COGNITO_USER_POOLS",
    "aws_content_delivery_bucket": "abucket-dev",
    "aws_content_delivery_bucket_region": "ap-southeast-2",
    "aws_content_delivery_url": "https://aurl.cloudfront.net"
};


export default awsmobile;

Azure AD SSO SignIn URL Tweak

The Azure AD Enterprise Application Sign On URL needs to be set in the following format:

https://COGNITOAUTHDOMAIN/oauth2/authorize?identity_provider=COGNITOSAMLIDENTITYPROVIDERNAME&redirect_uri=https://testapp.dunlop.geek.nz/&response_type=TOKEN&client_id=COGNITOCLIENTID

Please note: &scope=aws.cognito.signin.user.admin%20%email%20%openid%20%phone%20%profile has been removed from the URL as it caused an Error Code 0 failure thanks to an Azure AD back end change by Microsoft on or around the 10th of June 2019.

Javascript redirect

The redirect on the button example looks like this in the oauthbutton.js file. Note: the withOAuth() doesn’t really matter.

// file: OAuthButton.js
import { withOAuth } from 'aws-amplify-react';
import { withRouter } from 'react-router-dom';
import React, { Component } from 'react';

class OAuthButton extends React.Component {
  handleClick() {
    // do something meaningful, Promises, if/else, whatever, and then
    window.location.assign('https://weo-dev.auth.ap-southeast-2.amazoncognito.com/oauth2/authorize?identity_provider=AzureAD&redirect_uri=https://testapp.dunlop.geek.nz/&response_type=TOKEN&client_id=IAMANID&scope=aws.cognito.signin.user.admin email openid phone profile');
  }

  render() {
    return (
      <div class='login'>
        <button onClick={this.handleClick}>Log back into application with O365</button>
      </div>
    )
  }
}

//export default OAuthButton;
export default withOAuth(OAuthButton);

App.js example

/* ===================================================
  App.js : Core App.JS with Azure AD integrated authentication
  Author: Zenstorm
  Date: 10/05/2019
  Version: 1.00
*/

// ===================================================
// Imports
// from libraries:
import React, { Component } from 'react';
import Amplify, {Auth, Hub} from 'aws-amplify';
import awsmobile from './aws-exports'; // Amplify configuration
import { I18n, ConsoleLogger as Logger } from '@aws-amplify/core';
// from files:
import OAuthButton from './OAuthButton'; // Our login button "aka form" for now
import './App.css'; // Custom Style Sheet

// ===================================================
// setup Amplify and its Auth model
Amplify.configure(awsmobile);
Auth.configure({ awsmobile });  // https://aws-amplify.github.io/amplify-js/api/classes/authclass.html

// ===================================================
// set up logging properly so we can use logger.debug logger.error logger.info etc
const logger = new Logger('AppLog','INFO');

// ===================================================
// Main class for our Application that gets injected into the root in index.html via index.js
class App extends Component {
  // setup props
  constructor(props) {
    super(props);
    this.signOut = this.signOut.bind(this);
  }

  // define vars in state (could be done in props constructor)
  state = {
    authState: 'signIn', // used to check login in render()
    token: null, // used to check if we've got a valid login
    user: null // used to store the user object returned from currentAuthenticatedUser()
  }

  // ====================================================
  // getuserinfo(): custom function to get tokencode
  // getuserinfo function to retrieve via amplify & cognito the currentAuthenticatedUser
  getuserinfo = async () => {
    // call a promise to waith for currentAuthenticatedUser() to return
    const user = await Auth.currentAuthenticatedUser();
    
    // do a debug log entry
    logger.debug(user);

    // setup some variables out of our current user object
    const token = user.signInUserSession.idToken.jwtToken;
    const user_givenname = user.attributes.name;
    const user_email = user.attributes.email;

    // set the variables into the classes state.
    this.setState({ token: token });
    this.setState({ user: user });
    this.setState({ user_givenname: user_givenname });
    this.setState({ user_email: user_email });
  }

  // ====================================================
  // componentDidMount(): react core function
  // is invoked immediately after a component is mounted
  // https://reactjs.org/docs/react-component.html
  componentDidMount() {
    // Setup a hub listenr on the auth events
    // https://aws-amplify.github.io/docs/js/hub
    Hub.listen("auth", ({ payload: { event, data } }) => {
      switch (event) {
        case "signIn":
          this.setState({ authState: 'signedIn'});
          this.getuserinfo();
          break;
        case "signOut":
          this.setState({ authState: 'signIn'});
          this.setState({ user: null });
          break;
      }
    });
  }

  // ====================================================
  // signOut() : used to sign out user
  // custom sign out function; has been bound in constructor(props) as well
  signOut() {
    Auth.signOut()
      .then(() => {
        this.setState({ authState: 'signIn'});
        this.setState({ user: null });
      })
      .catch(err => {
        logger.error(err);
      });
  }

  // ====================================================
  // render(): mandatory react render function
  render() {
    // vars for fun - should be buried in app
    const { authState } = this.state;
    const { token } = this.state;
    const { user_givenname } = this.state;
    const { user_email } = this.state;

    // main return routine
    return (
      <div className="App">
        {
          // if authState is null display loading message.
          authState === null && (<div>loading...</div>)
        }
        {
          // if authState is set to signIn then show the login page with the single button for O365 javascript redirect
          authState === 'signIn' && <OAuthButton/>
        }
        {
          // if authState is signedIn then we've got a logged in user - lets start our app up!
          // or rather lets just show a sign out button for now.. sigh.
          authState === 'signedIn' ? 
          (
            <div class='signout'>
              <button onClick={this.signOut}>Sign out of application {user_givenname} {user_email}</button>
            </div>
          ) : null
        }
      </div>
    );
  }
}

// Export the App.  No Auth Wrapper required for AzureAD as this is undertaken in the OAuthButton.js
export default App;