Authentication Module

The authentication module code is under src/features/auth/*.

OAuth

We have a bigger picture of the whole flow (that includes backend) at the backend documentation. However, on this specific page, we will explain the flow of how the frontend communicates through a pop-up between two windows to provide a seamless user experience when login-in/registering a user on the system.

oauth-popup-flow-diagram

Figure 1. OAuth Popup Sequence Diagram

  • Starting off the idea that the API returns the OAuth URI computed for us, the UI should immediately trigger the opening of a new window with a popup nature.

  • In this popup we will run the OAuth flow (the user will take care of that)

  • By the end of the popup flow, a callback (that’s provided to the OAuth flow at the beginning) is called (this is specific for the code type of flow we also set at the beginning), this callback will be of the frontend specifically.

  • The callback and frontend uri is run at the popup level, and we extract through query params the code that’s the piece of data we need to continue further, we then send this to the parent through a postMessage() method that the browser’s API exposes, right after that the popup self-closes.

  • Once the parent receives the code that the popup provided sent, it can then run a request to a backend endpoint so that the backend starts under the hood the whole process of login/register and finally responding to the frontend with the result, in case of a success: We immediately should be greeted with the Home Screen.

It’s extremely important to state that we need to have configured the callback uri at the GCloud side, on the specific OAuth client. This has to correspond to both dev and prod environments also. Failing to have the callback uri registered will result in the OAuth screen to show an error and having no ability to run the authentication flow.

Routing on the frontend

We will always receive specifically at the Login screen. Regardless of a user already signing-in before, this is a security-measure, due to the sensitivy of the information the system holds.

At this screen we will ask the user to sign-up or login, regardless of which option is picked, we will always throw the user towards a Google OAuth popup, the login screen will remain open for the whole duration, and the moment the OAuth screen succeeds it will hit a /redirect-success route, that has a blank component in charge of extracting the code query param to then send a message back to the parent component.

The way to open the popup will be with the following code (demo code only):

async function startOAuth() {
    const url = 'http://localhost:5214/api/v1/auth/o-auth-uri/'; (1)

    const response = await fetch(url);
    const responseBody = await response.json();

    const width = 450; (2)
    const height = 600;
    const left = (window.screen.width - width) / 2;
    const top = (window.screen.height - height) / 2; (3)
    window.open(
        responseBody.uri, // URL to open (4)
        'GoogleAuth', // Window name (5)
        `width=${width},height=${height},top=${top},left=${left},popup` (6)
    );
}
1 We will have an endpoint on the backend in charge of composing the OAuth url that a browser can open.
2 We are starting with a small screen size for the pop that will open-up.
3 We want to open the popup at the middle of the screen, hence we calculate the coordinates for the left and top by diving the total width and height of the screen.
4 API Reference. In order to open a popup we should leverage the window.open function with three key arguments. The first one is the uri to open up.
5 A good practice is to give an identifier to this new browsing context, we could just do a _blank, but for our instance, giving it a specific name will allow for us to target with an <a> and <form> elements if we need so.
6 Following the different windowFeatures we can grant this new window size and position, plus the special popup feature that has minimum features (based on the browser’s own implementation). This is so that the popup window doesn’t look as bloated to the user.

Once the Google OAuth flow finishes, it will try to hit /redirect-success on the frontend, and this route is mapped to this blank component in charge of extracting the code that’s on a query param and sending it back to the parent:

function RedirectSuccess() {
  const params = new URLSearchParams(window.location.search); (1)
  const authCode = params.get('code'); (2)

  if (!window.opener) { (3)
    return;
  }

  window.opener.postMessage(
    { event: 'OAuth', authCode }, (4)
    window.opener.location.origin (5)
  );
  window.close(); (6)

  return <div></div>;
}

export { RedirectSuccess };
1 We can get access to a url query string (including the ? sign) by accessing the window.location.search property. We leverage this so that we get a string that can be put into a URLSearchParams constructor so that we get a much comfortable interface to extract query params.
2 With the URLSearchParams instance we can easily access the value from a set query param name.
3 Just to avoid useless messages, in case this component doesn’t have a parent attached to it, we won’t do anything and we will short-circuit the flow immediately.
4 We can post to the parent of the popup a message, in our case it will be a javascript object, with an event identifier (since many messages can be listened to and an identifier makes it easier for us to recognize a message from a popup child).
5 postMessage API So that we get access to the current url, we can leverage the read-only window.location.origin property, this returns the <protocol><hostname>:<port> string. This is a security measure, since in here we specify what origin the target recipient of the message mush have to receive the event. In order for the event to be dispatched the origin must match exactly. Hence we are feeding the opener’s origin.
6 After dispatching the message we self-close the pop-up window.

And at the parent side, we will simply declare a listener for messages, so that the moment we receive the OAuth message, we can kick off further logic:

const [message, setMessage] = useState('');
const [eventList, setEventList] = useState(new Array<string>());

const messageCallback = useCallback( (1)
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (event: any) => {
        if (event.origin !== window.location.origin) { (5)
            return;
        }

        if (event.data?.event !== 'OAuth') { (6)
            return;
        }

        const newEventList = [...eventList, event.data]; (7)
        setEventList(newEventList);
        setMessage(JSON.stringify(newEventList));
    },
    [eventList]
);

useEffect(() => {
    window.addEventListener('message', messageCallback); (2)

    return () => {
        window.removeEventListener('message', messageCallback); (3)
    };
}, [messageCallback]); (4)
1 We are leveraging the useCallback hook, so that we can cache a function definition between re-renders, this optimizes rendering, and we are using it here specifically since the same function definition must be passed to an addEventListener and removeEventListener to subscribe and unsubscribe once the component unmounts (we don’t want to have a leak there).
2 So that we can hear messages that are posted with postMessage, we can subscribe to the message events, and feed a callback for that specific event.
3 And when unsubscribing we have to feed the same function definition (a really bad design choice).
4 We are aiming at running the subscription only once after mounting, (but due to the dependency to the messageCallback function that talks to state we are adding it as a dependency).
5 This is double security, since from our previous example on the posting side, the event should never get here, unless the origin is the same, however, on the consumer side we are also making a validation so that if the event that came back doesn’t have the same origin as the current parent window we will return early.
6 And also, since many other events can end up getting posted, we will focus on the OAuth events solely, in case another event type comes in, we return early.
7 Lastly, since this is demo code we are applying the specific logic that helps us post as state an array and a stringified json from said array to the component. Then we can access these properties to render them on the screen. (In a real use case we would post this to some store or directly call the API to continue with the OAuth flow).

Talking to the backend

It’s once the parent screen receives the code from OAuth, that we will send that to the backend so that it can do everything on its own, and we wait for a response back:

useEffect(() => {
    async function postAuthCode() {
      const url = new URL('http://localhost:5214/api/v1/auth/o-auth-code/');
      url.searchParams.set('code', eventList[eventList.length - 1]); (1)

      const response = await fetch(url, { method: 'POST' }); (2)
      const responseBody = await response.json();

      if (response.status === 200) { (3)
        navigate('/login', { state: { code: responseBody.code } }); (4)
      }
    }
    if (eventList.length < 1) {
      return;
    }

    postAuthCode();
  }, [eventList]);
1 Always use APIs that are already there to make your life easier, do not hard wire or re-invent the wheel.
2 We make a post to the specific backend endpoint in order to send the code from OAuth.
3 In case the backend responds with a success, we will redirect the user to the home screen
4 We make use of the useNavigate() hook to work with React Router’s navigation.

Notes

  • When consuming React Router hooks plus other utilities, tests easily break due to the fact that we almost never test from main.tsx component that holds the wrapper context. It is for this reason that we have to hydrate our components with the correct React Router context through a clever usage of re-exports and wrappers

    • Under src/test-utils/testing-library-utils.tsx you can have a look at how we swap out our router for a MemoryRouter, this interface enables us to have the consumers feeding all properties that you can normally feed to a router and a render() function, plus wrapping a component (facade pattern), however under the hood we are changing the router implementation.

    • When testing a component that requires for us to have a router context, we can import this module instead: import { render, screen } from 'testing-library-utils';

    • In order for typescript and vitest to resolve all the remaps correctly we have to edit two places:

      • On the tsconfig.app.json or tsconfig.json file we have to add:

        "paths": {
          "testing-library-utils": ["./src/test-utils/testing-library-utils.tsx"]
        }
      • And on the vitest.config.ts side we have to also add:

        resolve: {
          alias: {
            'testing-library-utils': '/src/test-utils/testing-library-utils.tsx',
            src: '/src',
          },
        },