Client side Google authorization code model
This blogpost is a summary of steps of what you need to do in order to implement Google's code model authorization. I spent a whole day trying to move from Google's implicit grant model to code model which they recommend and I felt docs where incomplete and needed to search a lot for the right answers.
❓ The why
Why did I move from a backend-less approach to one that requires having backend endpoints? Well, I was a bit upset on the model I had where when we requested for Oauth2 authorization, we still needed to request user data from gapi
which increases complexity BUT, the main reason was that with implicit grant, you don't get refresh tokens.
What this means is that after 1 hour, a new access token needs to be retrieved by enforcing some user action (like clicking a button)
<button
className="btn btn-primary"
type="button"
onClick={() => {
tokenClient.requestAccessToken();
}}
>
Sign In
</button>
This generates loads of friction and frustration on the user because if they did some action at the point where the token expired, the data would fail to save.
When using code model, you do get a refresh_token back from Google.
👨💻 Implementation
I found it quite difficult when reading the official docs to understand the changes I had to make. Mostly, there's a bunch of words but lack of code examples and implications depending on if you want to do some things server or client side.
🔑 Retrieving an access key
The flow is hard to understand when you read their docs but quite simple once you do. First, you need to initialise a codeClient
instead of a tokenClient
:
const oauthClient = window.google.accounts?.oauth2.initCodeClient({
client_id: '<your_client_id_from_google_console>',
scope: 'https://www.googleapis.com/auth/drive.file',
ux_mode: 'popup', # popup is less friction
callback: async (response) => {
const resp = await authorize(response.code);
localStorage.setItem('session', JSON.stringify(resp)); # it's good to store in localStorage so you survive page refresh
},
});
This client is then used when clicking the button to grant permissions (it must be a user action that grants the oauth2 permission).
<button
className="btn btn-primary"
type="button"
onClick={() => {
codeClient?.requestCode();
}}
>
Go!
</button>
When you click the button, Google does it's stuff and when successful, triggers the callback you passed to the initCodeClient
passing it the response. In that callback, you need to forward response.code
to your backend endpoint that is in charge of exchanging the code for access and refresh tokens.
import { Auth } from 'googleapis';
app.get('/user/authorize', async (req, res) => {
const code = req.query.code as string;
const oauth2Client = new Auth.OAuth2Client(
'<your_client_id_from_google_console>',
'<your_client_secret_from_google_console>',
'postmessage'
);
const response = await oauth2Client.getToken(code);
res.json(response.tokens);
});
In the code above you see we create a new OAuth2Client
and then call getToken
with the received code.
You see that 'postmessage'? Well, that's supposed to be the redirect URI but when I tried to put something valid I was getting a uri redirect missmatch error. I then found this SO answer that clarified it.
Once successful, you get a response that looks like
{
"access_token": "<access_token>",
"scope": "https://www.googleapis.com/auth/drive.file",
"token_type": "Bearer",
"id_token": "id_token", # This is a JWT containing user info like email, name, etc.
"expiry_date": 1706777762379
}
In order to use it in your subsequent gapi
calls, you just need to set it with window.gapi.client.setToken({ access_token })
.
🔄 Refreshing expired access token
Note that official docs tell you to store refresh tokens in the backend and do the refreshing there. In here we do it in the frontend because I didn't want to create a DB just for that and because in previous iterations access tokens where already being stored in local storage of the user's browser. Before implementing this, make sure you're okay with the security implications.
When refreshing tokens, there are couple of options about when to do it:
- proactive one where you have a timer with the
expiry_date
and when it reaches then do the refresh call. - do a lazy refresh waiting for an action to fail, refresh and then retry (automated, don't move the burden to the users).
Regardless of the option you choose, you will need a backend endpoint to refresh access tokens because the refresh call requires secrets passed to it.
app.get('/user/refresh', async (req, res) => {
const refresh_token = req.query.refresh_token as string;
const oauth2Client = new Auth.OAuth2Client(
'<your_client_id_from_google_console>',
'<your_client_secret_from_google_console>',
'postmessage'
);
oauth2Client.setCredentials({
refresh_token,
});
const response = await oauth2Client.refreshAccessToken();
res.json(response.credentials);
});
This endpoints returns a response with all the same data as /user/authorize
. When you receive the successful response, you just need to re-set the accessToken
for your gapi
as before with window.gapi.client.setToken({ access_token })
.
There is a gotcha that I didn't find documented either. The
id_token
does not contain profile information for the user. You are supposed to cache/store things like name and picture when you do the authorize call.
🚀 useSession React hook
I created a useSession
hook to encapsulate credentials logic that can then be re-used in the rest of the app. The hook does the following:
- Syncs the tokens in
localStorage
. - Checks every 10 seconds if the access token has expired. If it has, it calls the refresh and re-sets the tokens.
- The hook exposes
setCredentials
which is useful for the Login page to set the credentials for the first time when authorizing (remember it needs user action from a user triggered action, i.e. clicking a button). - It exposes the session with the tokens so other places in the code can check that there's an access token (for example the Gapi client) and a
user
parameter with the information from the JWT token already decoded.
import React from 'react';
import { DateTime } from 'luxon';
import { useRouter } from 'next/navigation';
import { refresh } from '@/lib/Stocker';
import type { Credentials, User } from '@/types/user';
import { isStaging } from '@/helpers/env';
const emptyUser: User = {
name: '',
email: '',
image: '',
};
export type SessionReturn = {
session: Credentials | undefined;
user: User;
setCredentials: React.Dispatch<React.SetStateAction<Credentials | undefined>>;
revoke: Function;
};
/**
* This hook captures Authorization data from Google Oauth2
*/
export default function useSession(): SessionReturn {
const router = useRouter();
const [credentials, setCredentials] = React.useState<Credentials>();
/**
* Once the first render is finished, try to load
* the session from local storage.
*
* If it does not exist, redirect to /user/login.
* If it exists, set local state credentials.
*/
React.useEffect(() => {
const strSession = localStorage.getItem('session');
if (strSession) {
setCredentials(JSON.parse(strSession));
} else if (!isStaging()) {
router.push('/user/login');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* Set local storage session whenever local credentials state changes
*/
React.useEffect(() => {
if (credentials) {
localStorage.setItem('session', JSON.stringify(credentials));
}
}, [credentials]);
/**
* Check every 10 seconds if the access token has expired.
* If it has, then refresh the token.
*/
React.useEffect(() => {
let timer: number;
async function check(c: Credentials) {
await refreshIfExpired(c, setCredentials);
timer = window.setInterval(async () => {
await refreshIfExpired(c, setCredentials);
}, 10000);
}
if (credentials) {
check(credentials);
}
return () => clearInterval(timer);
}, [credentials]);
return {
session: credentials,
user: isStaging()
? { name: 'Maffin', email: 'iomaffin@gmail.com', image: '' }
: extractUser(credentials?.id_token),
setCredentials,
revoke: () => {
localStorage.removeItem('session');
router.push('/user/login');
},
};
}
async function refreshIfExpired(
credentials: Credentials,
setCredentials: React.Dispatch<React.SetStateAction<Credentials | undefined>>,
) {
const msLeft = credentials.expiry_date - DateTime.now().toMillis();
if (msLeft <= 0) {
const newCredentials = await refresh(credentials.refresh_token);
setCredentials({
...newCredentials,
// id_token is missing profile information when credentials
// are returned from a refresh call
id_token: credentials.id_token,
});
}
}
function extractUser(token?: string) {
if (!token) {
return emptyUser;
}
// Theoretically wrong but works for our use case
// https://stackoverflow.com/questions/38552003/how-to-decode-jwt-token-in-javascript-without-using-a-library
const data = JSON.parse(atob(token.split('.')[1]));
return {
name: data.name || '',
email: data.email || '',
image: data.picture || '',
};
}
💚 Conclusion
With this change, we've added some burden to our backend but we simplify some parts like:
- 👤 We retrieve user profile information when asking for Oauth2 scope. No more using
gapi.client.people
. - 🔄 No more user friction asking them hourly to go through Google Oauth consent screens!
Some good (but at the time of writing, incomplete) links to have at hand when working on this:
- Github gapi
- Authorization code model docs
- Oauth2 client side guide
- Previous implicit grant implementation for Maffin
You have the code available in the PR I opened when I shipped this for Maffin.