node.jsfirebaseoauth-2.0google-cloud-functionstauri

(Tauri) Can't refresh access token in cloud function - invalid_grant


I'm experimenting with Google OAuth2.0 authorization and access token refreshing in a Firebase cloud function. In my Tauri desktop app I'm using Firebase's signInWithPopup() function in order to retrieve access token, refresh token and Id token.

const provider = new GoogleAuthProvider();
provider.addScope('https://www.googleapis.com/auth/calendar')
        
const result = await signInWithPopup(auth, provider);

const credentials = GoogleAuthProvider.credentialFromResult(result)

const uid = result.user.uid
const idToken = await result.user.getIdToken()
const accessToken = credentials?.accessToken
const refreshToken = result.user.refreshToken

Then I'm calling my Firebase Google Cloud Function, where I'm sending the ID token and refresh token. In this function I would like to refresh the access token, since it can be expired:

const response = await fetch('https://us-central1-axel-86.cloudfunctions.net/refreshAccessToken', {
  method: 'POST',
  headers: {'Content-Type':'application/json'},
  body: JSON.stringify({ data: { uid, idToken, refreshToken }}),
});

The Firebase cloud funtion looks like this:

export const refreshAccessToken = onRequest({ cors: "*",  }, async (req, res) => {
  try {
    const { idToken, refreshToken } = req.body.data;
 
    const refreshedAccessToken = await refreshGoogleAccessToken(refreshToken);
    
    res.json({ refreshedAccessToken });
  } catch (e) {
    throw new HttpsError("unknown", 'Error', e)
  }
})

async function refreshGoogleAccessToken(refreshToken: string) {
  const oauth2Client = new google.auth.OAuth2(
      googleOauth.clientId,
      googleOauth.clientSecret,
      ''
  );
  oauth2Client.setCredentials({ refresh_token: refreshToken, scope: `https://www.googleapis.com/auth/calendar` });
  
  logger.debug("credentials - ok")

  // this does not work
  const refreshed = await oauth2Client.refreshAccessToken();

  logger.debug("refreshed - ok", refreshed )
  
  return refreshed.credentials.access_token;
}

The problem is that oauth2Client.refreshAccessToken() fails. In cloud functions log I can see only "credentials - ok" log, but not "refreshed - ok", plus there is an error:

GaxiosError: invalid_grant
    at Gaxios._request (/workspace/node_modules/gaxios/build/src/gaxios.js:142:23)
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    at async OAuth2Client.refreshTokenNoCache (/workspace/node_modules/google-auth-library/build/src/auth/oauth2client.js:212:19)
    at async OAuth2Client.refreshAccessTokenAsync (/workspace/node_modules/google-auth-library/build/src/auth/oauth2client.js:247:19)
    at async OAuth2Client.getAccessTokenAsync (/workspace/node_modules/google-auth-library/build/src/auth/oauth2client.js:276:23)
    at async refreshGoogleAccessToken (/workspace/lib/index.js:61:21)
    at async /workspace/lib/index.js:36:38 {
  config: {
    retry: true,
    retryConfig: {
      httpMethodsToRetry: [Array],
      currentRetryAttempt: 0,
      retry: 3,
      noResponseRetries: 2,
      retryDelayMultiplier: 2,
      timeOfFirstRequest: 1741544322630,
      totalTimeout: 9007199254740991,
      maxRetryDelay: 9007199254740991,
      statusCodesToRetry: [Array]
    },
    method: 'POST',
    url: 'https://oauth2.googleapis.com/token',
    data: '<<REDACTED> - See `errorRedactor` option in `gaxios` for configuration>.',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'User-Agent': 'google-api-nodejs-client/9.15.1',
      'x-goog-api-client': 'gl-node/22.14.0'
    },
    paramsSerializer: [Function: paramsSerializer],
    body: '<<REDACTED> - See `errorRedactor` option in `gaxios` for configuration>.',
    validateStatus: [Function: validateStatus],
    responseType: 'unknown',
    errorRedactor: [Function: defaultErrorRedactor]
  },
  response: {
    config: {
      retry: true,
      retryConfig: [Object],
      method: 'POST',
      url: 'https://oauth2.googleapis.com/token',
      data: '<<REDACTED> - See `errorRedactor` option in `gaxios` for configuration>.',
      headers: [Object],
      paramsSerializer: [Function: paramsSerializer],
      body: '<<REDACTED> - See `errorRedactor` option in `gaxios` for configuration>.',
      validateStatus: [Function: validateStatus],
      responseType: 'unknown',
      errorRedactor: [Function: defaultErrorRedactor]
    },
    data: { error: 'invalid_grant', error_description: 'Bad Request' },
    headers: {
      'alt-svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000',
      'cache-control': 'no-cache, no-store, max-age=0, must-revalidate',
      'content-encoding': 'gzip',
      'content-type': 'application/json; charset=utf-8',
      date: 'Sun, 09 Mar 2025 18:18:42 GMT',
      expires: 'Mon, 01 Jan 1990 00:00:00 GMT',
      pragma: 'no-cache',
      server: 'scaffolding on HTTPServer2',
      'transfer-encoding': 'chunked',
      vary: 'Origin, X-Origin, Referer',
      'x-content-type-options': 'nosniff',
      'x-frame-options': 'SAMEORIGIN',
      'x-xss-protection': '0'
    },
    status: 400,
    statusText: 'Bad Request',
    request: { responseURL: 'https://oauth2.googleapis.com/token' }
  },
  error: undefined,
  status: 400,
  [Symbol(gaxios-gaxios-error)]: '6.7.1'
}

What is wrong here? How could I refresh the access token? Should I use googleapis (gapi) lib for this?


functions/package.json:

"firebase-admin": "^13.2.0",
"firebase-functions": "^6.3.1",
"googleapis": "^146.0.0"

NOTE: As far as I understand, in this case proper way should be to use signInWithRedirect() instead of signInWithPopup() and set the redirect to another cloud function, where I would retrieve the refresh token, store it securely to db and then retrieve it from the db when calling the refreshAccessToken function instead of sending it in the call from Tauri directly. But for experimnetal reasons this should be fine and should not prevent access token refreshing..


Solution

  • I don't know what's the issue with access token refreshing in my example, but in the end I solved it by not using Firebase at all and instead of refreshing token in my cloud function I'm doing it on a local server opened from Tauri by using this plugin:

    https://github.com/FabianLars/tauri-plugin-oauth/tree/v2

    This plugin allows to open local server on specified port either from JS part, or from Rust part of the Tauri code and provides callback for handling the OAuth redirect. In my case I'm doing it from JS (not sure if it is secure enough).

    import {
      start,
      cancel,
      onInvalidUrl,
      onUrl,
    } from "@fabianlars/tauri-plugin-oauth";
    
    const googleOauth = {
      clientId: '***.apps.googleusercontent.com',
      clientSecret: '***',
      redirectUri: 'http://127.0.0.1:8899',
      authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
      tokenUrl: 'https://oauth2.googleapis.com/token'
    }
    
    function logIn() {
      startServer();
    
      const authUrl =
        `${googleOauth.authUrl}?` +
        `client_id=${googleOauth.clientId}&` +
        `redirect_uri=${googleOauth.redirectUri}&` +
        `access_type=offline&` +
        `prompt=consent&` +
        `response_type=code&` +
        `scope=https://www.googleapis.com/auth/calendar openid email profile`;
    
      openUrl(authUrl);
    }
    
    async function startServer() {
      const port = await start({ ports: [8899] });
    
      // process OAuth redirect in this callback
      const unlistenUrl = await onUrl(async (url: string) => {
        console.log("Received OAuth URL:", url);
    
        const urlObj = new URL(url);
        const params = new URLSearchParams(urlObj.search);
    
        const code = params.get("code");
        if (!code) {
          console.log("Not authenticated!", params);
          return;
        }
    
        // exchange authorization code for tokens
        const tokens = await exchangeTokens(code);
    
        // if needed, refresh access token
        setTimeout(async () => {
          await refreshAccessToken((tokens as any).refresh_token);
        }, 2000);
      });
    
      const unlistenInvalidUrl = await onInvalidUrl((error: any) => {
        console.error("Received invalid OAuth URL:", error);
      });
    
      // Store unlisten functions to call them when stopping the server
      (window as any).unlistenFunctions = [unlistenUrl, unlistenInvalidUrl];
    }
    
    async function exchangeTokens(authCode: string) => {
      const params = new URLSearchParams({
        code: authCode,
        client_id: googleOauth.clientId,
        client_secret: googleOauth.clientSecret,
        redirect_uri: googleOauth.redirectUri,
        grant_type: "authorization_code",
      });
    
      return await fetch(googleOauth.tokenUrl, {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: params.toString(),
      })
        .then((response) => response.json())
        .then((data) => {
          console.log("tokens", data);
          return data;
        })
        .catch((error) => console.error(error));
    };
    
    async function refreshAccessToken(refreshToken: string) => {
      const params = new URLSearchParams({
        client_id: googleOauth.clientId,
        client_secret: googleOauth.clientSecret,
        refresh_token: refreshToken,
        grant_type: 'refresh_token'
      });
    
      return await fetch(googleOauth.tokenUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: params.toString()
      })
      .then(response => response.json())
      .then(data => {
        const accessToken = data.access_token;
        const expiresIn = data.expires_in;
      
        console.log('New access token:', accessToken, " - expires in:", expiresIn);
        
        return data;
      })
      .catch(error => console.error('Error refreshing access token:', error));
    

    In google cloud console should be the local redirect url with port specified:

    Authorised redirect URIs:

    http://127.0.0.1:8899