5
votes
admin.auth().verifyIdToken(tokenId)
      .then((decoded) => res.status(200).send(decoded))

I understand verifyIdToken() can verify a user id token from Firebase Authentication clients. However we need to protect our Cloud Function by making sure database queries are limited by the security rules defined for the database for the user identified in the token. Given that the admin SDK has unlimited access by default, how to I limit its access to just that of the authenticated user?

1
Could you edit your question to explain what it means to "protect" a function? That is kind of vague.Doug Stevenson
We want to make sure our http trigger api endpoint's database read/write access follows the database-rules.json in firebase.JLIN
Our first attempt is using firebase web (signed in with token) instead of firebase admin in some of our get/update/set/remove accesses in our http trigger apis. It works but it is not scalable.JLIN
I'm curious to know what was not scalable about that? (It's definitely not the best solution, but what are you measuring?)Doug Stevenson
` const firebase = require('firebase'); const userGet = (validFirebasePath) => firebaseDatabase .database() .ref(validFirebasePath) .once('value') .then(snapshot => snapshot.val()); exports.apiHandler = (request, response) => { // we get accessToken from the request getCustomToken(accessToken) .then((customToken) => firebase.auth().signInWithCustomToken(customToken) .then(() => userGet(‘users/userA’)) .then(data => response.json(data)) }; ` Here is our first attempt.JLIN

1 Answers

13
votes

Take a look at the following HTTPS function. It performs the following tasks:

  1. Verifies a Firebase Authentication ID token using the Admin SDK. The token comes from the query string (but you should use a better solution to transmit the token).
  2. Pulls the user's UID out of the decoded token.
  3. Makes a copy of the default Firebase init configuration object, then adds a property called databaseAuthVariableOverride to it, using the UID, to limit the privileges of the caller.
  4. Initializes a new non-default instance of an App object (named "user") with the new options. This App object can now be used to access the database while observing security rules in place for that user.
  5. The Admin SDK is used along with userApp to make a database query to some protect path.
  6. If the query was successful, remember the response to send to the cleint.
  7. If the query failed due to security rues, remember an error response to send to the client.
  8. Clean up this instance of the Admin SDK. This code takes all precautions to make sure userApp.delete() is called in all circumstances. Don't forget to do this, or you will leak memory as more users access this function.
  9. Actually send the response. This terminates the function.

Here's a working function:

const admin = require("firebase-admin")
admin.initializeApp()

exports.authorizedFetch = functions.https.onRequest((req, res) => {
    let userApp
    let response
    let isError = false

    const token = req.query['token']

    admin.auth().verifyIdToken(token)
    .then(decoded => {
        // Initialize a new instance of App using the Admin SDK, with limited access by the UID
        const uid = decoded.uid
        const options = Object.assign({}, functions.config().firebase)
        options.databaseAuthVariableOverride = { uid }
        userApp = admin.initializeApp(options, 'user')
        // Query the database with the new userApp configuration
        return admin.database(userApp).ref("/some/protected/path").once('value')
    })
    .then(snapshot => {
        // Database fetch was successful, return the user data
        response = snapshot.val()
        return null
    })
    .catch(error => {
        // Database fetch failed due to security rules, return an error
        console.error('error', error)
        isError = true
        response = error
        return null
    })
    .then(() => {
        // This is important to clean up, returns a promise
        if (userApp) {
            return userApp.delete()
        }
        else {
            return null
        }
    })
    .then(() => {
        // send the final response
        if (isError) {
            res.status(500)
        }
        res.send(response)
    })
    .catch(error => {
        console.error('final error', error)
    })
})

Again, note that userApp.delete() should be called in all circumstances to avoid leaking instances of App. If you had the idea to instead give each new App a unique name based on the user, that's not a good idea, because you can still run out of memory as new users keep accessing this function. Clean it up with each call to be safe.

Also note that userApp.delete() should be called before the response is sent, because sending a response terminates the function, and you don't want to have the cleanup interrupted for any reason.