2

I have a Firebase application that authenticates a user and returns an access token that I can then use to access the Google Calendar and Sheets API. I also save the refreshToken. Sample code for authenticated token:

firebase
  .signInWithGoogle()
  .then(async (socialAuthUser) => { 
    let accessToken = socialAuthUser.credential.accessToken // token to access Google Sheets API
    let refreshToken = socialAuthUser.user.refreshToken
    this.setState({accessToken, refreshToken}) 
 })

After 1 hour, the accessToken expires. Firebase auth provides a refresh token on the user object after sign-in

I use that refresh token to re-authenticate and get a new access_token by posting to:

https://securetoken.googleapis.com/v1/token?key=firebaseAppAPIKey

That new access token does not work for Google APIs anymore, and it doesn't have the authorized scopes anymore. I also try sending it to

https://www.googleapis.com/oauth2/v1/tokeninfo?access_token="refreshToken"

It gives me the error "Invalid token". When I use the original token from firebase, it works just fine.

Anyone else encountering a similar issue? I haven't figured out a way to refresh the original access token with the correct access scopes without making the user sign-out and sign-in again.

Thanks!

2 Answers2

8

I was finally able to solve it after many attempts.

Posted detailed solution on Medium: https://inaguirre.medium.com/reusing-access-tokens-in-firebase-with-react-and-node-3fde1d48cbd3

On the client, I used React with the Firebase library, and on the server I used Node.js with the packages google-apis and the firebase-admin skd package linked to the same Firebase project.

Steps:

  1. (CLIENT) Send a request to the server to generate an authentication link
  2. (SERVER) Generate Auth Link and send it back to the client using the getAuthLink() from googleapis. Sign in with Google and handle the redirect.
  3. (SERVER) On the redirect route, use the code from Google on the query string to authenticate the user and get his user credentials. Use these credentials to check if the user is registered on Firebase.
  4. (SERVER) If the user is registered, get the access and refresh tokens using the oauth2.getTokens(code), update refresh token on the user profile in the database. If the user is not registered, create a new user with firebase.createUser(), also create the user profile on the database with the refresh token.
  5. (SERVER) Use firebase.createCustomToken(userId) to send an id_token back to client and authenticate.
  6. (SERVER) Use a res.redirect({access_token, referesh_token, id_token}) to send credentials back to client.
  7. (CLIENT) On the client, use the signInWithCustomToken(id_token) to authenticate, also restructure the query to obtain access_token and refresh_token to send API calls.
  8. (CLIENT) Set an expiration date for the access token. On each request, check if the current date is higher than the expiration date. If it is, request a new token to https://www.googleapis.com/oauth2/v4/token with the refresh token. Otherwise use the access_token stored.

Most stuff happens when handling the Google Redirect after authentication. Here's an example of handling auth and tokens on the backend:

const router = require("express").Router();

const { google } = require("googleapis");

const { initializeApp, cert } = require("firebase-admin/app");

const { getAuth } = require("firebase-admin/auth");
const { getDatabase } = require("firebase-admin/database");
const serviceAccount = require("../google-credentials.json");

const fetch = require("node-fetch");

initializeApp({
  credential: cert(serviceAccount),
  databaseURL: "YOUR_DB_URL",
});

const db = getDatabase();

const oauth2Client = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  "http://localhost:8080/handleGoogleRedirect"
);

//post to google auth api to generate auth link
router.post("/authLink", (req, res) => {
  try {
    // generate a url that asks permissions for Blogger and Google Calendar scopes
    const scopes = [
      "profile",
      "email",
      "https://www.googleapis.com/auth/drive.file",
      "https://www.googleapis.com/auth/calendar",
    ];

    const url = oauth2Client.generateAuthUrl({
      access_type: "offline",
      scope: scopes,
      // force access
      prompt: "consent",
    });
    res.json({ authLink: url });
  } catch (error) {
    res.json({ error: error.message });
  }
});

router.get("/handleGoogleRedirect", async (req, res) => {
  console.log("google.js 39 | handling redirect", req.query.code);
  // handle user login
  try {
    const { tokens } = await oauth2Client.getToken(req.query.code);
    oauth2Client.setCredentials(tokens);

    // get google user profile info
    const oauth2 = google.oauth2({
      version: "v2",
      auth: oauth2Client,
    });

    const googleUserInfo = await oauth2.userinfo.get();

    console.log("google.js 72 | credentials", tokens);

    const userRecord = await checkForUserRecord(googleUserInfo.data.email);

    if (userRecord === "auth/user-not-found") {
      const userRecord = await createNewUser(
        googleUserInfo.data,
        tokens.refresh_token
      );
      const customToken = await getAuth().createCustomToken(userRecord.uid);
      res.redirect(
        `http://localhost:3000/home?id_token=${customToken}&accessToken=${tokens.access_token}&userId=${userRecord.uid}`
      );
    } else {
      const customToken = await getAuth().createCustomToken(userRecord.uid);

      await addRefreshTokenToUserInDatabase(userRecord, tokens);

      res.redirect(
        `http://localhost:3000/home?id_token=${customToken}&accessToken=${tokens.access_token}&userId=${userRecord.uid}`
      );
    }
  } catch (error) {
    res.json({ error: error.message });
  }
});

const checkForUserRecord = async (email) => {
  try {
    const userRecord = await getAuth().getUserByEmail(email);
    console.log("google.js 35 | userRecord", userRecord.displayName);
    return userRecord;
  } catch (error) {
    return error.code;
  }
};

const createNewUser = async (googleUserInfo, refreshToken) => {
  console.log(
    "google.js 65 | creating new user",
    googleUserInfo.email,
    refreshToken
  );
  try {
    const userRecord = await getAuth().createUser({
      email: googleUserInfo.email,
      displayName: googleUserInfo.name,
      providerToLink: "google.com",
    });

    console.log("google.js 72 | user record created", userRecord.uid);

    await db.ref(`users/${userRecord.uid}`).set({
      email: googleUserInfo.email,
      displayName: googleUserInfo.name,
      provider: "google",
      refresh_token: refreshToken,
    });

    return userRecord;
  } catch (error) {
    return error.code;
  }
};

const addRefreshTokenToUserInDatabase = async (userRecord, tokens) => {
  console.log(
    "google.js 144 | adding refresh token to user in database",
    userRecord.uid,
    tokens
  );
  try {
    const addRefreshTokenToUser = await db
      .ref(`users/${userRecord.uid}`)
      .update({
        refresh_token: tokens.refresh_token,
      });
    console.log("google.js 55 | addRefreshTokenToUser", tokens);
    return addRefreshTokenToUser;
  } catch (error) {
    console.log("google.js 158 | error", error);
    return error.code;
  }
};

router.post("/getNewAccessToken", async (req, res) => {
  console.log("google.js 153 | refreshtoken", req.body.refresh_token);

  // get new access token
  try {
    const request = await fetch("https://www.googleapis.com/oauth2/v4/token", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        client_id: process.env.GOOGLE_CLIENT_ID,
        client_secret: process.env.GOOGLE_CLIENT_SECRET,
        refresh_token: req.body.refresh_token,
        grant_type: "refresh_token",
      }),
    });
    const data = await request.json();
    console.log("google.js 160 | data", data);
    res.json({
      token: data.access_token,
    });
  } catch (error) {
    console.log("google.js 155 | error", error);
    res.json({ error: error.message });
  }
});

module.exports = router;
Dharman
  • 30,962
  • 25
  • 85
  • 135
3

For anyone who comes across this now, there is a much easier way at this point.

I was able to solve this by implementing a blocking function that simply saved the refreshToken and exiry date to firestore. You can then query this from your frontend to get the tokens there as well.

Be sure to enable the refreshToken in the firebase settings, otherwise the blocking function won't have access to it.

https://firebase.google.com/docs/auth/extend-with-blocking-functions

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import {
  AuthEventContext,
  AuthUserRecord,
} from "firebase-functions/lib/common/providers/identity";

admin.initializeApp();

exports.beforeSignIn = functions.auth
  .user()
  .beforeSignIn((user: AuthUserRecord, context: AuthEventContext) => {
    // If the user is created by Yahoo, save the access token and refresh token
    if (context.credential?.providerId === "yahoo.com") {
      const db = admin.firestore();

      const uid = user.uid;
      const data = {
        accessToken: context.credential.accessToken,
        refreshToken: context.credential.refreshToken,
        tokenExpirationTime: context.credential.expirationTime,
      };

      // set will add or overwrite the data
      db.collection("users").doc(uid).set(data);
    }
  });
folkg
  • 51
  • 1
  • 4
  • 1
    +1000000 this was the only way that worked for me and was super easy to do... https://firebase.google.com/docs/auth/extend-with-blocking-functions#accessing_a_users_identity_provider_oauth_credentials – frg100 Jan 18 '23 at 02:29