Apr 18, 2022

OAuth2 Authorization with React

React with OAuth2

If you want to use OAuth2 authorization in your React project you can use my published package: @tasoskakour/react-use-oauth2.

What is OAuth2?

OAuth2 is the industry-standard protocol for authorization. It provides us with specific authorization flows for web applications, desktop applications, mobile phones and living room devices. You can read more about the framework here.

Where do we use OAuth2 on the web?

One of the major uses of OAuth2 protocol is authentication and authorization with 3rd party providers such as Google, Facebook, Apple etc. The so called "Social Login". I'm pretty sure that most of you have used Social Login with OAuth2 (as an end-user) in the past.

In the picture below we can see that the user can either Sign in manually with the traditional way of providing username and password (right side) or they can sign in via Facebook/Twitter/Google (left side). By choosing to login with one of those social providers, they basically are authenticating with that provider and authorize the application to have access to certain information of their social account. For example if they choose to login with Google, after a successful authentication, the app may know their Google email, profile photo and name.

Social login

One of the benefits of using a social login is its simplicity. With one click and without even having to enter a username and password we can just start using an app in a matter of seconds. Of course we need to make sure that the app is trustworthy and we always need to pay attention to what information the application will eventually gather from our social account.

OAuth2 Grant types

The most common OAuth2 Grant types are Authorization Code and Implicit Flow.

OAuth 2.0 Authorization Code Grant

The Authorization Code grant type is used by confidential and public clients to exchange an authorization code for an access token. The steps of this flow are:

  1. Client (your app) constructs and sends the user to an authorization URL that has the form of:

    https://authorization-server.com/auth?
    response_type=code&
    client_id=CLIENT_ID&
    redirect_uri=REDIRECT_URI&
    scope=photos&
    state=1234zyx

    E.g for google the base authorization URL would be:

    https://accounts.google.com/o/oauth2/v2/auth
  2. The user sees the authorization prompt and clicks "Allow" (or "Deny"). (Probably the user will be asked to authenticate first).
  3. User is being redirected to the redirect_uri that was initially passed to the request on the first step along with a code and the state. This URL will has the form of:

    https://REDIRECT_URI.com/callback?
    code=AUTH_CODE_HERE&
    state=1234zyx

    Note: the state parameter must match the state initially generated on step 1. This is to mitigate CSRF attacks. Read more here.

  4. In this step we need to exchange the received code with an actual access token. To accomplish this we need to make a POST request to the authorization server's token endpoint that has the form as seen below. Note that this request needs to be made from our server as we must not expose the CLIENT_SECRET to the front-end app.

    https://api.authorization-server.com/token
    grant_type=authorization_code&
    code=AUTH_CODE_HERE&
    redirect_uri=REDIRECT_URI&
    client_id=CLIENT_ID&
    client_secret=CLIENT_SECRET

    E.g for Google the full /token URL is:

    https://oauth2.googleapis.com/token
  5. The authorization server replies back with the access token and an expiration time:

    {
    "access_token":"RsT5OjbzRn430zqMLgV3Ia",
    "expires_in":3600
    }

Note: Authorization Code flow is generally considered secure. However it can be even more bulletproof by using the PKCE (RFC 7636) extension which utilizes code_verifier, code_challenge and a code_challenge_method. This extension is beyond the scope of this article. You can read more about it here.

Implicit Flow Grant

The Implicit flow is a simplified OAuth flow for JavaScript apps where the access token is returned immediately without an extra authorization code exchange step.

This means that the steps for Implicit Flow are the steps 1-3 that we wrote above for Authorization Code grant. Also one more difference is that the redirect URL will contain the access_token in the hash (#) parameters and not in the search (?) parameters of the URL. It will have the form of:

https://REDIRECT_URI.com/callback#
access_token=ACCESS_TOKEN&
expires_in=3600&
token_type=Bearer&
state=1234zyx

Nowadays the OAuth 2.0 standard recommends to use Authorization Code Grant instead of Implicit Flow due to the inherent risks of returning access tokens in an HTTP redirect without any confirmation that it has been received by the client.

However, most major providers still support and let apps use Implicit Flow. For example here you can read Google's documentation for OAuth 2.0 for Client-side Web Applications which basically uses an Implicit Flow.

React: Implement OAuth2 with hooks

In this section we're going to create a React hook called useOAuth2 that will implement an OAuth2 with Authorization Code Grant.

If you want to use OAuth2 authorization in your React project you can use my published package: @tasoskakour/react-use-oauth2.

To make the authorization more user-friendly we are going to utilize a Popup; that is the authorization to the 3rd party will happen inside the Popup along with the redirection.


Step 1: Prepare the hook

The props that our hook will need are:

  • authorizeUrl: The 3rd party authorization URL.
  • clientId: The OAuth2 client id of your application.
  • redirectUri: Determines where the 3rd party API server redirects the user after the user completes the authorization flow. In our implementation a Popup will be rendered on that redirectUri.
  • scope (string - optional): A list of scopes depending on your application needs.

Let's get those props to our hook and let's create a UI state helper that will include {loading, error} and a function called getAuth. This function will initialize the authorization flow so as a first step it will need to set the loading to true and clear any errors.

// useOAuth2.js
import { useCallback, useState } from 'react'; 

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });
  })
}

Step 2: Generate state

Before constructing the authorization URL, we need to generate a state parameter. This parameter is needed to mitigate CSRF attacks. Also we could pass any other state information we wanted here in order to restore later after the redirection.

To generate the state we make use of the window.crypto function. The code to accomplish this is taken from this article.

We also need to persist the state to sessionStorage so that our Popup will be able to read it later after the redirection takes place (we could persist it to cookies or localStorage as well).

// useOAuth2.js
import { useCallback, useState } from 'react'; 

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';

// https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50
const generateState = () => {
	const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
	let array = new Uint8Array(40);
	window.crypto.getRandomValues(array);
	array = array.map((x: number) => validChars.codePointAt(x % validChars.length));
	const randomState = String.fromCharCode.apply(null, array);
	return randomState;
};

const saveState = (state: string) => {
	sessionStorage.setItem(OAUTH_STATE_KEY, state);
};

const removeState = () => {
	sessionStorage.removeItem(OAUTH_STATE_KEY);
};

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });

      // 2. Generate and save state
      const state = generateState();
      saveState(state);
  })
}

Step 3: Open the Popup

And now we'll start the Authorization process!

We create the authorization URL utilizing the enhanceAuthorizeUrl function that takes the authorizeUrl, clientId, redirectUri, scope, state parameters and we open a Popup to that URL. For better Popup manipulation we make use of useRef to hold the Popup instance.

// useOAuth2.js
import { useCallback, useState } from 'react'; 

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';
const POPUP_HEIGHT = 700;
const POPUP_WIDTH = 600;

// https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50
const generateState = () => {
	const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
	let array = new Uint8Array(40);
	window.crypto.getRandomValues(array);
	array = array.map((x) => validChars.codePointAt(x % validChars.length));
	const randomState = String.fromCharCode.apply(null, array);
	return randomState;
};

const saveState = (state) => {
	sessionStorage.setItem(OAUTH_STATE_KEY, state);
};

const removeState = () => {
	sessionStorage.removeItem(OAUTH_STATE_KEY);
};

const openPopup = (url) => {
	// To fix issues with window.screen in multi-monitor setups, the easier option is to
	// center the pop-up over the parent window.
	const top = window.outerHeight / 2 + window.screenY - POPUP_HEIGHT / 2;
	const left = window.outerWidth / 2 + window.screenX - POPUP_WIDTH / 2;
	return window.open(
		url,
		'OAuth2 Popup',
		`height=${POPUP_HEIGHT},width=${POPUP_WIDTH},top=${top},left=${left}`
	);
};

const closePopup = (popupRef) => {
	popupRef.current?.close();
};

const enhanceAuthorizeUrl = (
	authorizeUrl,
	clientId,
	redirectUri,
	scope,
	state
) => {
	return `${authorizeUrl}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}`;
};

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const popupRef = useRef();
  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });

      // 2. Generate and save state
      const state = generateState();
      saveState(state);

      // 3. Open popup
      popupRef.current = openPopup(
        enhanceAuthorizeUrl(authorizeUrl, clientId, redirectUri, scope, state)
      );
  })
}

Create the Popup Component

Let's leave useOAuth2 hook for now and let's concentrate on the Popup implementation.

Now we're in a state where the user is inside a Popup and performs the authorization with the 3rd party. After that they will be redirected to the redirect_uri we passed along in the initial request.

A convenient practice is to render the Popup with a React Route for that redirect_uri. For example make redirect_uri to be https://your-app.com/callback and then create the Routes as seen in the code block below. In this way, the authorization server will redirect us back to the redirect_uri which will render only our Popup component.

// routes.js
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { OAuthPopup } from 'OAuth2Popup';

const Example = () => (
	<BrowserRouter>
		<Routes>
			<Route element={<OAuthPopup />} path="/callback" />
			<Route element={<Home />} path="/" />
            {/* ... your other routes ... */}
		</Routes>
	</BrowserRouter>
);

Now let's move on with the Popup component. It needs to take care of 3 things:

  1. Read the URL redirection parameters code and state. If an error has occured there will be an error parameter.
  2. Check that the state parameter matches the one that was initially sent to the request; we read that parameter from sessionStorage.
  3. Communicate back to the parent window (window.opener) that the authorization either succeeded or failed.

In order to establish communication between the Popup and it's Opener we make use of window.opener.postMessage function which sends messages to the window that the Opener can listen to.

The steps above are translated to the code below:

// OAuth2Popup.jsx
import { useEffect } from 'react';
import { queryToObject } from './tools';

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';
const OAUTH_RESPONSE = 'react-use-oauth2-response';

const checkState = (receivedState) => {
	const state = sessionStorage.getItem(OAUTH_STATE_KEY);
	return state === receivedState;
};

const queryToObject = (query) => {
	const parameters = new URLSearchParams(query);
	return Object.fromEntries(parameters.entries());
};

const OAuthPopup = (props) => {
	const {
		Component = (
			<div style={{ margin: '12px' }} data-testid="popup-loading">
				Loading...
			</div>
		),
	} = props;

	// On mount
	useEffect(() => {
		const payload = queryToObject(window.location.search.split('?')[1]);
		const state = payload && payload.state;
		const error = payload && payload.error;

		if (!window.opener) {
			throw new Error('No window opener');
		}

		if (error) {
			window.opener.postMessage({
				type: OAUTH_RESPONSE,
				error: decodeURI(error) || 'OAuth error: An error has occured.',
			});
		} else if (state && checkState(state)) {
			window.opener.postMessage({
				type: OAUTH_RESPONSE,
				payload,
			});
		} else {
			window.opener.postMessage({
				type: OAUTH_RESPONSE,
				error: 'OAuth error: State mismatch.',
			});
		}
	}, []);

	return Component;
};

export default OAuthPopup;

Step 4: Listen to Popup messages

Let's move back to our useOAuth2 hook.

After we open the Popup we need to register a Message Listener to the window that will listen to any messages coming from the Popup. In order to distinguish from all the other window messages, the Popup sets the type of the message to react-use-oauth2-response.

If the Popup returned an error then we set the appropriate UI state, otherwise we get the message payload which includes the code parameter.

There's also a scenario where the user forcefully closed the Popup before completing authorization. In order to catch this, we set an interval that periodically checks if the popup was forcefully closed and if so it performs some cleanup tasks like resetting the UI, removing the message listener etc.

// useOAuth2.js
import { useCallback, useState } from 'react'; 

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';
const POPUP_HEIGHT = 700;
const POPUP_WIDTH = 600;
const OAUTH_RESPONSE = 'react-use-oauth2-response';

// https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50
const generateState = () => {
	const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
	let array = new Uint8Array(40);
	window.crypto.getRandomValues(array);
	array = array.map((x) => validChars.codePointAt(x % validChars.length));
	const randomState = String.fromCharCode.apply(null, array);
	return randomState;
};

const saveState = (state) => {
	sessionStorage.setItem(OAUTH_STATE_KEY, state);
};

const removeState = () => {
	sessionStorage.removeItem(OAUTH_STATE_KEY);
};

const openPopup = (url) => {
	// To fix issues with window.screen in multi-monitor setups, the easier option is to
	// center the pop-up over the parent window.
	const top = window.outerHeight / 2 + window.screenY - POPUP_HEIGHT / 2;
	const left = window.outerWidth / 2 + window.screenX - POPUP_WIDTH / 2;
	return window.open(
		url,
		'OAuth2 Popup',
		`height=${POPUP_HEIGHT},width=${POPUP_WIDTH},top=${top},left=${left}`
	);
};

const closePopup = (popupRef) => {
	popupRef.current?.close();
};

const cleanup = (
	intervalRef,
	popupRef,
	handleMessageListener
) => {
	clearInterval(intervalRef.current);
	closePopup(popupRef);
	removeState();
	window.removeEventListener('message', handleMessageListener);
};

const enhanceAuthorizeUrl = (
	authorizeUrl,
	clientId,
	redirectUri,
	scope,
	state
) => {
	return `${authorizeUrl}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}`;
};

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const popupRef = useRef();
  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });

      // 2. Generate and save state
      const state = generateState();
      saveState(state);

      // 3. Open popup
      popupRef.current = openPopup(
        enhanceAuthorizeUrl(authorizeUrl, clientId, redirectUri, scope, state)
      );

      // 4. Register message listener
      async function handleMessageListener(message) {
        try {
          const type = message && message.data && message.data.type;
          if (type === OAUTH_RESPONSE) {
            const errorMaybe = message && message.data && message.data.error;
            if (errorMaybe) {
              setUI({
                loading: false,
                error: errorMaybe || 'Unknown Error',
              });
            } else {
              const code = message && message.data && message.data.payload && message.data.payload.code;
              // ... Check next step to see what we'll do with the code
            }
          }
        } catch (genericError) {
          console.error(genericError);
          setUI({
            loading: false,
            error: genericError.toString(),
          });
        } finally {
          // Clear stuff ...
          cleanup(intervalRef, popupRef, handleMessageListener);
        }
      }
      window.addEventListener('message', handleMessageListener);

      // 4. Begin interval to check if popup was closed forcefully by the user
      intervalRef.current = setInterval(() => {
        const popupClosed = !popupRef.current || !popupRef.current.window || popupRef.current.window.closed;
        if (popupClosed) {
          // Popup was closed before completing auth...
          setUI((ui) => ({
            ...ui,
            loading: false,
          }));
          console.warn('Warning: Popup was closed before completing authentication.');
          clearInterval(intervalRef.current);
          removeState();
          window.removeEventListener('message', handleMessageListener);
        }
      }, 250);

      // Remove listener(s) on unmount
      return () => {
        window.removeEventListener('message', handleMessageListener);
        if (intervalRef.current) clearInterval(intervalRef.current);
      };
    })
}

Step 5: Exchange code with an access token

The final step is to exchange the received code with an actual access_token.

In order to accomplish this we'll need a server because we must not expose the client_secret to the front-end app. Then, our server will make a POST request to the 3rd party authorization server /token endpoint to actually get the access_token. E.g for Google this is https://oauth2.googleapis.com/token. Also note that the specs for each provider may differ.

The server route we need to create is really simple. For the sake of this tutorial we will implement it with fastify framework:

// server.js
import Fastify from 'fastify';
import fetch from 'node-fetch';

const fastify = Fastify({
	logger: true,
});

const CLIENT_SECRET = process.env.CLIENT_SECRET;
const AUTHORIZATION_SERVER_TOKEN_URL = process.env.AUTHORIZATION_SERVER_TOKEN_URL; // e.g https://oauth2.googleapis.com/token

fastify.post('/token', async (request, reply) => {
	const { code, client_id, redirect_uri } = request.query;

	const data = await fetch(
		`${AUTHORIZATION_SERVER_TOKEN_URL}?grant_type=authorization_code&client_id=${client_id}&client_secret=${CLIENT_SECRET}&redirect_uri=${redirect_uri}&code=${code}`,
		{
			method: 'POST',
		}
	);

	reply.send(await data.json());
});

fastify.listen(3001, (error) => {
	if (error) throw error;
});
// useOAuth2.js
import { useCallback, useState } from 'react'; 

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';
const POPUP_HEIGHT = 700;
const POPUP_WIDTH = 600;
const OAUTH_RESPONSE = 'react-use-oauth2-response';

// https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50
const generateState = () => {
	const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
	let array = new Uint8Array(40);
	window.crypto.getRandomValues(array);
	array = array.map((x) => validChars.codePointAt(x % validChars.length));
	const randomState = String.fromCharCode.apply(null, array);
	return randomState;
};

const saveState = (state) => {
	sessionStorage.setItem(OAUTH_STATE_KEY, state);
};

const removeState = () => {
	sessionStorage.removeItem(OAUTH_STATE_KEY);
};

const openPopup = (url) => {
	// To fix issues with window.screen in multi-monitor setups, the easier option is to
	// center the pop-up over the parent window.
	const top = window.outerHeight / 2 + window.screenY - POPUP_HEIGHT / 2;
	const left = window.outerWidth / 2 + window.screenX - POPUP_WIDTH / 2;
	return window.open(
		url,
		'OAuth2 Popup',
		`height=${POPUP_HEIGHT},width=${POPUP_WIDTH},top=${top},left=${left}`
	);
};

const closePopup = (popupRef) => {
	popupRef.current?.close();
};

const cleanup = (
	intervalRef,
	popupRef,
	handleMessageListener
) => {
	clearInterval(intervalRef.current);
	closePopup(popupRef);
	removeState();
	window.removeEventListener('message', handleMessageListener);
};

const enhanceAuthorizeUrl = (
	authorizeUrl,
	clientId,
	redirectUri,
	scope,
	state
) => {
	return `${authorizeUrl}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}`;
};

const objectToQuery = (object) => {
	return new URLSearchParams(object).toString();
};

const formatExchangeCodeForTokenServerURL = (
	serverUrl,
	clientId,
	code,
	redirectUri
) => {
	return `${serverUrl}?${objectToQuery({
		client_id: clientId,
		code,
		redirect_uri: redirectUri,
	})}`;
};

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const popupRef = useRef();
  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });

      // 2. Generate and save state
      const state = generateState();
      saveState(state);

      // 3. Open popup
      popupRef.current = openPopup(
        enhanceAuthorizeUrl(authorizeUrl, clientId, redirectUri, scope, state)
      );

      // 4. Register message listener
      async function handleMessageListener(message) {
        try {
          const type = message && message.data && message.data.type;
          if (type === OAUTH_RESPONSE) {
            const errorMaybe = message && message.data && message.data.error;
            if (errorMaybe) {
              setUI({
                loading: false,
                error: errorMaybe || 'Unknown Error',
              });
            } else {
              const code = message && message.data && message.data.payload && message.data.payload.code;
              const response = await fetch(
                formatExchangeCodeForTokenServerURL(
                  'https://your-server.com/token',
                  clientId,
                  code,
                  redirectUri
                )
              );
              if (!response.ok) {
                setUI({
                  loading: false,
                  error: "Failed to exchange code for token",
                });
              } else {
                payload = await response.json();
                setUI({
                  loading: false,
                  error: null,
                });
                setData(payload);
                // Lines above will cause 2 rerenders but it's fine for this tutorial :-)
              }
            }
          }
        } catch (genericError) {
          console.error(genericError);
          setUI({
            loading: false,
            error: genericError.toString(),
          });
        } finally {
          // Clear stuff ...
          cleanup(intervalRef, popupRef, handleMessageListener);
        }
      }
      window.addEventListener('message', handleMessageListener);

      // 4. Begin interval to check if popup was closed forcefully by the user
      intervalRef.current = setInterval(() => {
        const popupClosed = !popupRef.current || !popupRef.current.window || popupRef.current.window.closed;
        if (popupClosed) {
          // Popup was closed before completing auth...
          setUI((ui) => ({
            ...ui,
            loading: false,
          }));
          console.warn('Warning: Popup was closed before completing authentication.');
          clearInterval(intervalRef.current);
          removeState();
          window.removeEventListener('message', handleMessageListener);
        }
		  }, 250);

      // Remove listener(s) on unmount
      return () => {
        window.removeEventListener('message', handleMessageListener);
        if (intervalRef.current) clearInterval(intervalRef.current);
      };
    })
}

The End

I hope you found this article informative. 🙂

In a following article we will talk more about how we can specifically use Google OAuth2 and then the Google APIs to fetch information about the user.

Stay tuned!

Loading Comments...