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.
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');
});