Generating and storing OAuth 2.0 access tokens with Firebase

Adam Gerhant
7 min readAug 26, 2023

--

In order for your app to use Google API’s to perform actions with a Google account, the owner of the account must login and authorize these permissions. In this article I will explain how I generate the tokens through OAuth 2.0 and store these tokens for later use in the Firestore database. The API I will be authorizing is the Gmail API, and the Firebase services that will be used are Authentication, Cloud Functions, and Firestore. GCP API’s and services will also be used. Cloud Functions will handle storage of the keys, so the document which they are stored can be locked from public access. This enables maximum security, since the client never has access to the keys. This process can be used for any Google API, such as Google Calendar, Drive, Sheets, and so on.

System overview

Initializing OAuth and API’s

Before we can start writing code, OAuth 2.0 and the API you want to use must be initialized in Google Cloud Platform (GCP). First, Go to Services and API’s in your GCP console, and create an OAuth client ID.

When the process is complete, click the save JSON button to save the keys. The keys will be used later to connect the Cloud Functions to the OAuth 2.0 server. Set the authorized redirect URLs to the oAuthCallback Cloud Function, which will be created later, as well as authorized origins.

The second step is to enable the API you want to use. Navigate to the library, search for the API, and click enable.

Getting ID token and sending it to Cloud Function

Once GCP is set up, the first step is to get the users ID token from Firebase Authentication in you client. Use the following code in your client to get the token, and send it to the Cloud Function endpoint.

const auth = getAuth();
if(auth.currentUser){
auth.currentUser.getIdToken(true).then(function(idToken) {
fetch('https://us-central1-oauth-test.cloudfunctions.net/api/googleLogin', {
method: "POST",
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify({"token": idToken})
}).then((response) => response.json())
.then((result) => {
window.open(result.url, "_self")
}).catch(function(error) {
console.log("failed to fetch "+error)
});
}).catch(function(error) {
console.log("couldnt get user token "+error)
});
}

Generating OAuth URL

The following boilerplate will set up your Cloud Function environment with all the necessary libraries and endpoints. It also creates a secure connection to the OAuth 2.0 server using the OAuth ID keys. The googleLogin endpoint is requested by the user, and the oAuthCallback endpoint is requested by Google OAuth.

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const { google } = require('googleapis');
const url = require('url');
const express = require('express');
const app = express();
const cors = require('cors');

admin.initializeApp();
const oauth2Client = new google.auth.OAuth2(
"[Client ID]",
"[Client secret]",
"https://us-central1-oauth-test.cloudfunctions.net/api/oAuthCallback"
);
app.use(cors({origin:"http://localhost:3000"}));

app.post('/googleLogin', (request, response) => {
//...
})
app.get('/oAuthCallback', async (req, response) => {
//...
})

exports.api = functions.https.onRequest(app);

This is the code that will generate the OAuth URL. Make sure to authorize whatever scopes are needed for your API usage. The full list can be found here: https://developers.google.com/identity/protocols/oauth2/scopes. I use the full mail.google.com scope, since the send and compose scopes were not working. After extensive research online, I have found that it is believed the Gmail API is bugged. The send and compose scopes do not seem to give the correct access to send. In general though, it is best to use granular permissions. Another important part is passing the uid in the state when generating the AuthURL. This is how the callback function will know where to store the keys.

app.post('/googleLogin', (request, response) => {
const scopes = [
'https://www.googleapis.com/auth/userinfo.email',
'https://mail.google.com/'
//'https://www.googleapis.com/auth/gmail.send',
//'https://www.googleapis.com/auth/gmail.compose',
];

admin.auth().verifyIdToken(request.body.token)
.then((decodedToken) => {
const uid = decodedToken.uid;
const authorizationUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: scopes,
include_granted_scopes: true,
prompt: "consent",
state: uid
});
response.set('Cache-Control', 'private, max-age=0, s-maxage=0');
response.send({"url": authorizationUrl});
})
})
.catch((error) => {
response.status(400).send({error: error});
});
});

Redirect to Authorization Page

Once the AuthURL is generated and sent to the client, the client must be redirected. The following .then() statement after your client side POST request will accomplish this.

.then((response) => response.json())
.then((result) => {
window.open(result.url, "_self")
})

This should open the following window in a new tab.

Once you sign in, a page that says “Google hasn’t verified this app” will pop up. You can get passed this by clicking “advanced” and then “go to us-central1-oauth-test.cloudfunctions.net (unsafe)” The only way to remove this intermediate screen is to verify your app in the GCP OAuth page section. After getting past this screen, the user will be prompted to give access for the scopes your requested.

OAuth Callback Function

When the user finishes the final page of the Authorization process, it will send a request to the oAuthCallback function that was defined earlier. It will send the tokens required to use API’s on behalf of the authorized user. This includes the access token, refresh token, and id token. Since we sent the user ID in the state, it will also be returned. After these tokens are saved to the database, you can send a URL in the response, which is where the user will be redirected to. It is important to check if the user actually gave access, since the callback will be called even if no access was given. This is what the code looks like.

app.get('/oAuthCallback', async (req, response) => {  
let q = url.parse(req.url, true).query;
if (q.error) { // An error response e.g. error=access_denied
console.log('Error:' + q.error);
} else { // Get access and refresh tokens (if access_type is offline)
let { tokens } = await oauth2Client.getToken(q.code);
oauth2Client.setCredentials(tokens);
const oauth2 = google.oauth2({
auth: oauth2Client,
version: 'v2',
});
const userID = q.state
const { data } = await oauth2.userinfo.get();
const { email } = data;
const { refresh_token, id_token, access_token } = tokens;
const res = await oauth2.tokeninfo({
access_token: access_token,
id_token: id_token,
});

//check if user gave access to scope
if(res.data.scope.includes("https://mail.google.com")){
// Store the refresh token in the Firestore database.
const firestore = admin.firestore();
try{
await firestore.doc("users/"+userID+"/data/emailData").set({ email, refresh_token }, { merge: true });
var redirectUrl = new URL("http://localhost:3000/authorizeEmail?email="+email+"&success=true");
response.redirect(redirectUrl);
}
catch{
var redirectUrl = new URL("http://localhost:3000/authorizeEmail?email="+email+"&success=false");
response.redirect(redirectUrl);
}
}
else{
var redirectUrl = new URL("http://localhost:3000/authorizeEmail?email="+email+"&success=false");
response.redirect(redirectUrl);
}
}
});

Authorization Complete Page

Once the callback returns the URL to OAuth, the OAuth server returns the URL back to the user. Since the client is never involved in the generation or storage process, it is very secure. The authorization complete page should display whether or not authorization was successful and if tokens have been stored in the database. For inspiration, here is what my page looks like and the code behind it. The frameworks/libraries I am using are React, NextJS, and Tailwind. The language is Typescript.

or

'use client'
import React, { useEffect, useState } from "react"
import { useSearchParams } from 'next/navigation'
import { User } from "@/@types/user";
import { onAuthStateChanged } from "firebase/auth";
import auth from "../firebase";
import { useRouter } from "next/navigation";

const AuthorizeEmail = () => {
const [currentUser, setCurrentUser] = useState<User>({uid:"", email:""});
const router = useRouter();

useEffect(() => {
onAuthStateChanged(auth, (user) => {
console.log("setting current user")
const currentUserObj: User = {uid:"", email:""};
if(user?.uid){
currentUserObj.uid = user.uid;
if(user.email){
currentUserObj.email = user.email;
}
else{
currentUserObj.email = "Guest";
}
}
else{
router.push("/login")
}
console.log(currentUserObj)
setCurrentUser(currentUserObj);
})

}, []);
const googleoauth = () => {
if(auth.currentUser){
auth.currentUser.getIdToken(true).then(function(idToken) {
fetch('https://us-central1-oauth-test.cloudfunctions.net/api/googleLogin', {
method: "POST",
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify({"token": idToken})
}).then((response) => response.json())
.then((result) => {
window.open(result.url, "_self")
}).catch(function(error) {
console.log("failed to fetch "+error)
});
}).catch(function(error) {
console.log("couldnt get user token "+error)
});
}
}

const searchParams = useSearchParams()
const email = searchParams.get('email')
const success = searchParams.get('success')
if(currentUser.email&&email){
return(
<div className="h-full flex flex-column justify-center">
<div className="rounded p-6 mt-10">
<p className="text-3xl mb-4">Authorization {success=="true"?"complete":"incomplete"}</p>
{success=="true"&&
<p className="text-lg">Your Print Submit account
<b> {currentUser.email} </b>
will now send automated emails from
<b> {email}</b>
</p>}

{success=="false"&&
<>
<p className="text-lg">
Email permisson not granted. Please try again
</p>
<button className="authorizeButton" onClick={()=>googleoauth()}><img style={{width:"20px", marginRight:"5px", marginBottom:"1px"}}src={'/g-logo.png'} alt="Google logo"/>
Authorize Google account
</button>
</>
}
<button className="border-[1px] border-black px-2 rounded mt-4 text-lg" onClick={()=>{router.push("/emails")}}>Back to dashboard</button>
</div>
</div>
)
}
else{
return(<></>)
}

}
export default AuthorizeEmail;

Final Thoughts

While developing this system, I couldn’t find any information about saving the refresh tokens to a database. I spent dozens of hours trying countless aproaches, until I was reading through some documentation and finally found that you could pass state to the callback. This allows the refresh token to be saved to the correct user, without ever sending the tokens to the client. Now that the refresh token is saved, you can use it to access any API at any time. I hope this article gave a good understanding of the process, and helped you skip the hours of trial and error it took me to finally get it to work.

--

--