This is a continuation of the series on developing browser extensions with Google Sign In using Google OAuth. In the previous post, we looked at how to set up a Chrome extension with chrome.identity.launchWebAuthFlow
and Google Sign In as an alternative to chrome.identity.getAuthToken
. In this post, we will look at how to handle the token exchange and refresh for the Google OAuth flow in a Chrome extension using and external server API running in a Cloudflare Worker.
There are two case that we need to handle with the API:
- The initial token exchange to get the access token and refresh token from the authorization code returned by the OAuth flow
- The token refresh to get a new access token when the current access token expires
Setting up a Cloudflare Worker to handle the token exchange #
This API uses the Hono framework and handles the token exchange after the OAuth flow has completed.
The extension will:
- Get the authorization code from the OAuth flow
- Send the authorization code to the Cloudflare Worker API
- The API will exchange the authorization code for the access token and refresh token
- The API will return the access token and refresh token to the extension
import { Hono } from "hono";
type Env = {
CLIENT_ID: string;
CLIENT_SECRET: string;
REDIRECT_URI: string;
};
type OAuthTokenResponse = {
access_token: string;
expires_in: number;
id_token: string;
scope: string;
token_type: string;
refresh_token?: string;
};
const app = new Hono<{ Bindings: Env }>();
app.post("/api/auth/token", async (c) => {
const clientId = c.env.CLIENT_ID;
const clientSecret = c.env.CLIENT_SECRET;
const redirectUri = c.env.REDIRECT_URI;
const body = await c.req.json();
const now = Math.floor(Date.now() / 1000);
try {
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
code: body.code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: "authorization_code",
}),
});
if (response.ok) {
const {
access_token: accessToken,
expires_in: expiresIn,
refresh_token: refreshToken,
} = (await response.json()) as OAuthTokenResponse;
return c.json(
{
accessToken,
expiresAt: now + expiresIn,
refreshToken,
},
200,
);
}
const data = await response.json();
console.error("request failed: ", data);
return c.json(data, 400);
} catch (error) {
console.error("Error with authorization_code request: ", error);
return c.json({ error }, 400);
}
});
export default app;
Stepping through the code:
- The required ENV variables are the
CLIENT_ID
,CLIENT_SECRET
, andREDIRECT_URI
which are used to make the request to the Google OAuth API. These are set in the Cloudflare Worker environment variables (wrangler.toml
) and orginiate from the Google Cloud Console OAuth client settings. TheREDIRECT_URI
must match the one that was set in the initial OAuth flow request from the extension. - The API listens for POST requests to
/api/auth/token
from the extension and expects a JSON body with thecode
from the OAuth flow. - A request is made to the Google OAuth API to exchange the
code
for theaccess_token
andrefresh_token
. - If the request is successful, the
access_token
,expiresAt
, andrefresh_token
are returned to the extension. expiresAt
is calculated by adding theexpires_in
value to the current time in seconds. This is used by the extension to determine when the access token will expire. Access tokens from Google expire after an hour but this will account for any discrepancies in expiration time.
The grant_type
is set to authorization_code
since the API is exchanging the authorization code for the access token and refresh token.
"Content-Type": "application/json"
header allows the payload to be sent as JSON. If "Content-Type": "application/x-www-form-urlencoded"
is used, the body should be formatted as a query string.
After the API is set up and deployed, the extension can now make requests to the API to exchange the authorization code for the access token and refresh token.
Update the extension manifest.json
host permissions #
The host_permissions
in the manifest.json
will need updated to since the extension will need to make requests to the external API:
"host_permissions": [
"<api-endpoint>/*"
],
Updating the Chrome Extension to handle the token exchange #
Now, back in the extension, we need to update the callback function from the OAuth flow to send the authorization code to the API. The extension will then store the access token and refresh token in the Chrome storage.
const onLaunchWebAuthFlow = async () => {
try {
const authUrl = new URL("https://accounts.google.com/o/oauth2/auth")
const clientId = "<your-oauth-client-id>"
// Note: this needs to match the one used on the server (below)
// note the lack of a trailing slash
const redirectUri = `https://${chrome.runtime.id}.chromiumapp.org`
const state = Math.random().toString(36).substring(7)
const scopes = "profile email <other scopes>"
authUrl.searchParams.set("state", state)
authUrl.searchParams.set("client_id", clientId)
authUrl.searchParams.set("redirect_uri", redirectUri)
authUrl.searchParams.set("scope", scopes)
authUrl.searchParams.set("response_type", "code")
authUrl.searchParams.set("access_type", "offline")
authUrl.searchParams.set("include_granted_scopes", "true")
authUrl.searchParams.set("prompt", "consent")
chrome.identity.launchWebAuthFlow(
{
url: authUrl.href,
interactive: true,
},
async (redirectUrl) => {
if (chrome.runtime.lastError || !redirectUrl) {
return new Error(
`WebAuthFlow failed: ${chrome.runtime.lastError.message}`,
)
}
const params = new URLSearchParams(redirectUrl.split("?")[1])
const code = params.get("code")
if (!code) {
return new Error("No code found")
}
+ let response: Response
+ try {
+ response = await fetch(
+ `${API_BASE_URL}/api/auth/token`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ code,
+ }),
+ },
+ )
+
+ const { accessToken, expiresAt, refreshToken } =
+ await response.json()
+
+ if (accessToken) {
+ // save the tokens and expiration time to Chrome Storage
+ await chrome.storage.local.set({
+ accessToken,
+ refreshToken,
+ expiresAt,
+ })
+ }
} catch (error) {
throw new Error(`OAuth Sign-in failed: ${error.message}`)
}
},
)
} catch (error) {
throw new Error(`Sign-in failed: ${error.message}`)
}
}
If you’re making the request for the access and refresh token from the server but the refresh_token
is not included in the response, then you’ll need to reauthorize the user to get the refresh_token
again. This is because the refresh_token
is only returned the first time the user authorizes the app.
The app permissions can be revoked at https://myaccount.google.com/permissions. So if you’ve been testing along the way, you’ll likely need to revoke the permissions to get the refresh_token
again.
Signing user in with Firebase using the access token (optional) #
Now that the extension has the access token and refresh token, it can use the access token to sign the user in with Firebase. This is done by creating a GoogleAuthProvider
credential with the access token and then signing in with the credential.
//...
const credential = GoogleAuthProvider.credential(
null,
accessToken,
)
await signInWithCredential(getAuth(), credential)
//...
Now, the extension use onAuthStateChanged
to listen for changes in the user’s sign-in state and update the UI accordingly.
onAuthStateChanged(auth, (user) => {
//...
})
Nice, now the extension can handle the token exchange for the Google OAuth flow and sign the user in with Firebase (optional). This allows the extension to access Google APIs and Firebase services with the user’s credentials.
The next post will look at how to handle the OAuth token refresh when the access token expires.
If this post was helpful, consider signing up for the newsletter (below) to get updates when new posts are published.