firebasegoogle-cloud-platformfirebase-authenticationgoogle-cloud-functions

Firebase ID token has incorrect "aud" (audience) claim when authenticating Firebase function to function calls


I'm attempting to have one GCP Firebase Cloud Function call another. I am using https://cloud.google.com/functions/docs/securing/authenticating#authenticating_function_to_function_calls as the guide. I have everything working but when the function is invoked, it throws the following error:

Failed to validate auth token. FirebaseAuthError: Firebase ID token has incorrect "aud" (audience) claim. Expected "{projectId}" but got "https://{project-region}-{projectId}.cloudfunctions.net/{functionName}". Make sure the ID token comes from the same Firebase project as the service account used to authenticate this SDK.

I attempted to set the targetAudience to {projectId}, but then auth.getIdTokenClient(targetAudience); failed with a 401 Unauthorized response.

The called/invoked function is using functions.https.onCall to authenticate the request. If I switch it to functions.https.onRequest, it works, but I don't know how to validate the request and I think that's a pretty poor workaround anyway as it should be working with the onCall method.

For the functions.https.onRequest method, it passes through a Google Auth signed JWT Authorization header, but const decodedToken = await admin.auth().verifyIdToken(req.headers.authorization ?? ''); (source) fails with the error:

Error: Decoding Firebase ID token failed. Make sure you passed the entire string JWT which represents an ID token.

Solution

  • I needed to use Google Auth's OAuth2Client.verifyIdToken. This is not well documented. I had to find the solution in a sample code file; even then, it wasn't clear how you should verify the token's payload (their example verification method seemed rather weak). So here is my example of handling the full request:

    
    import { OAuth2Client } from 'google-auth-library';
    
    export const exampleFunction = functions.https.onRequest(async (req, res) => {
      // Note that I couldn't find a way to get the function name from the request object. :(
      const functionName = 'exampleFunction';
      // Note that you may have a different service account email if your Cloud Function 
      // is managed by a different account than the default.
      const expectedServiceAccountEmail = `your-project-id@appspot.gserviceaccount.com`;
      const parts = req.headers.authorization?.split(' ');
      if (!parts || parts.length !== 2 || parts[0] !== 'Bearer' || !parts[1]) {
        console.error('Bad header format: Authorization header not formated as \'Bearer [token]\'', req.headers);
        throw new functions.https.HttpsError('unauthenticated', 'user not authenticated');
      }
      try {
      const audience = `${req.protocol}://${req.hostname}/${functionName}`;
      const googleOAuth2Client = new OAuth2Client();
        const decodedToken = await googleOAuth2Client.verifyIdToken({
          idToken: parts[1],
          audience,
        });
        const payload = decodedToken.getPayload();
        if (!payload) {
          console.error('unpexpected state; missing payload', decodedToken);
          throw new Error('no payload');
        }
        if (payload.aud !== audience) {
          console.error('bad audience', payload);
          throw new functions.https.HttpsError('permission-denied', 'bad audience');
        }
        if (payload.iss !== 'https://accounts.google.com') {
          console.error('bad issuer', payload);
          throw new functions.https.HttpsError('permission-denied', 'bad issuer');
        }
        if (payload.exp < Date.now() / 1000) {
          console.error('expired token', payload);
          throw new functions.https.HttpsError('permission-denied', 'expired token');
        }
        if (!payload.email_verified) {
          console.error('email not verified', payload);
          throw new functions.https.HttpsError('permission-denied', 'email not verified');
        }
        if (payload.email !== expectedServiceAccountEmail) {
          console.error('invalid email', payload);
          throw new functions.https.HttpsError('permission-denied', 'invalid email');
        }
      } catch (e) {
        console.error(e);
        throw new functions.https.HttpsError('permission-denied', 'bad authorization id token');
      }
      res.status(200).send('ok');
    });