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.
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 apostMessage()
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 aMemoryRouter
, this interface enables us to have the consumers feeding all properties that you can normally feed to a router and arender()
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
ortsconfig.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', }, },
-
-