mike hennessy
mike hennessy

Reputation: 1689

CustomToken not working when issued through "createCustomToken" method

I am using Firebase authentication for my application and using it to authenicate users to a back-end API using JWT tokens. On the API back-end I've configured the JWT-secret, which is the asymmetric keys pulled from this url:

https://www.googleapis.com/service_accounts/v1/jwk/[email protected]

This is all working fine. I recently needed to create a cloud function, which needs to call the API back-end as well. To do this, I'm using the functionality to create a Custom Token found here:

https://firebase.google.com/docs/auth/admin/create-custom-tokens

This creates my token with correct custom claims

          let additionalClaims = {
             'x-hasura-default-role': 'admin',
             'x-hasura-allowed-roles': ['user', 'admin']
           }     

        admin.auth().createCustomToken(userId,additionalClaims).then(function (customToken) {
        console.log(customToken);

        response.end(JSON.stringify({
          token: customToken
        }))
      })
      .catch(function (error) {
       console.log('Error creating custom token:', error);
    });

however, when I try to use it against the back-end API, I get the "JWTInvalidSignature" error. In my cloud function, I specify the service account that is in my firebase project, but it doesn't seem to help. When I view the two tokens decoded, they definitely appear coming from different services.

CustomToken

     {
      "aud": 
       "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit",
       "iat": 1573164629,
       "exp": 1573168229,
       "iss": "[email protected]",
       "sub": "[email protected]",
       "uid": "mikeuserid",
        "claims": {
           "x-hasura-default-role": "admin",
           "x-hasura-allowed-roles": [
           "user",
           "admin"
        ]
       }
    }

TOKEN from FireBase Auth

 {
      "role": "webuser",   
      "schema": "customer1",
      "userid": "15",
      "claims": {
      "x-hasura-default-role": "user",
      "x-hasura-allowed-roles": [
      "user",
      "admin"
  ],
    "x-hasura-user-id": "OS2T2rdkM5UlhfWLHEjNExZ71lq1",
    "x-hasura-dbuserid": "15"
   },
   "iss": "https://securetoken.google.com/postgrest-b4c8c",
   "aud": "postgrest-b4c8c",
   "auth_time": 1573155319,
   "user_id": "OS2T2rdkM5UlhfWLHEjNExZ71lq1",
   "sub": "OS2T2rdkM5UlhfWLHEjNExZ71lq1",
   "iat": 1573164629,
   "exp": 1573168229,
   "email": "[email protected]",
   "email_verified": false,
   "firebase": {
    "identities": {
     "email": [
    "[email protected]"
  ]
  },
    "sign_in_provider": "password"
  }
 }

How can I get this customToken to work with the existing JWT secret keys I have configured??

Upvotes: 3

Views: 2073

Answers (1)

samthecodingman
samthecodingman

Reputation: 26246

As documented in in Firebase Authentication: Users in Firebase Projects: Auth tokens, the tokens from Firebase Auth and the Admin SDK Custom Tokens are not the same, incompatible with each other and are verified differently.


Edited response after clarification:

As you are trying to identify the cloud functions instance as an authorative caller of your third-party API, you may use two approaches.

In both of the below methods, you would call your API using postToApi('/saveUserData', { ... }); in each example. You could probably also combine/support both server-side approaches.

Method 1: Use a public-private key pair

For this version, we use a JSON Web Token to certify that the call is coming from a Cloud Functions instance. In this form of the code, the 'private.key' file is deployed along with your function and it's public key kept on your third-party server. If you are calling your API very frequently, consider caching the 'private.key' file in memory rather than reading it each time.

If you ever wish to invalidate this key, you will have to redeploy all your functions that make use of it. Alternatively, you may modify the fileRead() call and store it in Firebase Storage (secure it - readable by none, writable by backend-admin). Which will allow you to refresh the private key periodically by simply replacing the file.

  • Pros: Only one remote request
  • Cons: Updating keys could be tricky
const jwt = require('jsonwebtoken');
const rp = require('request-promise-native');
const functionsAdminId = 'cloud-functions-admin';

function getFunctionsAuthToken(jwtOptions) {
  jwtOptions = jwtOptions || {};
  return new Promise((resolve, reject) => {
    // 'private.key' is deployed with function
    fs.readFile('private.key', 'utf8', (err, keyData) => {
      if (err) { return reject({src: 'fs', err: err}); }

      jwt.sign('cloud-functions-admin', keyData, jwtOptions, (err, token) => {
        if (err) { return reject({src: 'jwt', err: err}); }
        resolve(token);
      });
    });
  });
}

Example Usage:

function postToApi(endpoint, body) {
  return getFunctionsAuthToken()
    .then((token) => {
      return rp({
          uri: `https://your-domain.here${endpoint}`,
          method: 'POST',
          headers: {
            Authorization: 'Bearer ' + token
          },
          body: body,
          json: true
        });
    });
}

If you are using express on your server, you can make use of express-jwt to deserialize the token. If configured correctly, req.user will be 'cloud-functions-admin' for requests from your Cloud Functions.

const jwt = require('express-jwt');

app.use(jwt({secret: publicKey});

Method 2: Add a cloud-functions-only user

An alternative is to avoid the public-private key by using Firebase Auth. This will have the tradeoff of potentally being slower.

  • Pros: No key management needed, easy to verify user on server
  • Cons: Slowed down by Firebase Auth calls (1-2)
const admin = require('firebase-admin');
const rp = require('request-promise-native');
const firebase = require('firebase');
const functionsAdminId = 'cloud-functions-admin';

function getFunctionsAuthToken() {
  const fbAuth = firebase.auth();
  if (fbAuth.currentUser && fbAuth.currentUser.uid == uid) {
    // shortcut
    return fbAuth.currentUser.getIdToken(true)
      .catch((err) => {src: 'fb-token', err: err});
  }

  return admin.auth().createCustomToken(functionsAdminId)
      .then(function(customToken) {
        return fbAuth.signInWithCustomToken(token)
          .then(() => {
            return fbAuth.currentUser.getIdToken(false)
              .catch((err) => {src: 'fb-token', err: err});
          })
          .catch((err) => {src: 'fb-login', err: err});
      })
      .catch((err) => {src: 'admin-newtoken', err: err});
}

Example Usage:

function postToApi(endpoint, body) {
  return getFunctionsAuthToken()
    .then((token) => {
      return rp({
          uri: `https://your-domain.here${endpoint}`,
          method: 'POST',
          headers: {
            Authorization: 'Bearer ' + token
          },
          body: body,
          json: true
        });
    });
}

On your server, you would use the following check:

// idToken comes from the received message
admin.auth().verifyIdToken(idToken)
  .then(function(decodedToken) {
    if (decodedToken.uid != 'cloud-functions-admin') {
      throw 'not authorized';
    }
  }).catch(function(error) {
    // Handle error
  });

Or if using express, you could attach it to a middleware.

app.use(function handleFirebaseTokens(req, res, next) {
  if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
    var token = req.headers.authorization.split(' ')[1];
    admin.auth().verifyIdToken(idToken)
      .then((decodedToken) => {
        req.user = decodedToken;
        next();
      }, (err) => {
        //ignore bad tokens?
        next();
      });
  } else {
    next();
  }
});

// later on: req.user.uid === 'cloud-functions-admin'


Original response:

If your client uses Firebase Authentication from an SDK to log in and your server uses the Admin SDK, you can use the client's ID token on the cloud function to speak to your server to verify a user by essentially "passing the parcel".

Client side

firebase.auth().currentUser.getIdToken(/* forceRefresh */ true).then(function(idToken) {
  // Send token to your cloud function
  // ...
}).catch(function(error) {
  // Handle error
});

Cloud Function

// idToken comes from the client app
admin.auth().verifyIdToken(idToken) // optional (best-practice to 'fail-fast')
  .then(function(decodedToken) {
    // do something before talking to your third-party API
    // e.g. get data from database/secret keys/etc.
    // Send original idToken to your third-party API with new request data
  }).catch(function(error) {
    // Handle error
  });

Third-party API

// idToken comes from the client app
admin.auth().verifyIdToken(idToken)
  .then(function(decodedToken) {
    // do something with verified user
  }).catch(function(error) {
    // Handle error
  });

Upvotes: 3

Related Questions