React-redux-firebase: discussion(auth): auth custom claims in routing

Created on 2 Sep 2018  路  16Comments  路  Source: prescottprue/react-redux-firebase

Hi,

I would like to use firebase auth custom claims to validate if a user is allowed to access the current route (and redirect if not).

I am having a hard time trying to figure out where to put this logic in the flow. Currently I'm using the firebaseAuthIsReady callback:

store.firebaseAuthIsReady.then(async () => {
    console.log("Auth has loaded");

    const user = firebase.auth().currentUser;

    if (!user) {
      console.log("no user");
      return;
    }
    const idTokenResult = await user.getIdTokenResult();

    console.log("claims", idTokenResult.claims);

    if (idTokenResult.claims.admin === true) {
      console.log("ADMIN USER");

      // store this so that route component can access
      window.isAdminUser = true;
    } else {
      console.log("NOT ADMIN USER");

      // store this so that route component can access
      window.isAdminUser = false;
    }
  });

Besides that if feels wrong to store this boolean on the window object, it also seems too late for the rendering routes to pick this up and redirect when they are instantiated.

Do you have an idea where I'm going wrong? Can I store this variable in the store so the routes can listen to it?

examples question

All 16 comments

A few thoughts:

  • You may want to do this in onAuthStateChanged (can be passed to config and recieves arguments (authData, firebase, dispatch)) - that way you know is for sure changing, and you will have access to dispatch (noted in example below)
  • It might help keep things more clear to go with dispatching an action and adding a reducer to update state instead of using a window variable (noted in example below)
  • Custom claims can get interesting if you are planning on changing the values after the user has logged in since they were need to re-auth to get changed info (in having to write code to force re-auth in the past, I usually just stick to placing info like this on the profile)

Code example:

onAuthStateChanged: (authData, firebase, dispatch) => {
  if (!auth) {
    console.log("no auth");
    return;
  }
  const user = firebase.auth().currentUser;
  const idTokenResult = await user.getIdTokenResult();
  dispatch({ type: 'CLAIMS_UPDATE', payload: idTokenResult }) // some custom action
}

Then write a reducer to handle the 'CLAIMS_UPDATE' action type and update state accordingly.

Following with interest because this is where I am.
If I understand correctly..
Firebase custom claims are:

  • more secure because they are encapsulated in signed payload
  • available directly from the UserRecord object; and
  • and therefore more efficient for database read/write permissions

However, it seems like the downsides are the following:

  • needing to re-auth the user when claims change
  • typically you'd also have permissions persisted somewhere anyway (user's profile is logical), so then you need to make sure you're in sync with the claims object which is not easily inspectable

So it seems like your advice is to skip claims altogether..is that right? I'm more for ease of maintenance for sure..

@mlake That is correct. There are ways around keeping things in sync, but in my experience it has always been much more tooling that was worth.

We should probably get something about this into the docs since I am sure others have had similar questions.

But using profile to store auth things is insecure because profile can be manipulated using client side firebase, and the change is not simply local. Letting users change roles as they like is not a good idea IMO. That's why custom claims can only be set in server SDK.

@hinsxd That is not true if you write your rules correctly. It is common practice to have a place within the database which is only viewable/editable by the user themselves (i.e. a private profile) as well as a place where all other users can view by id, but not write (i.e. a public profile).

From there you can have a function (or other server) update the public collection on write to the private collection. Since it is only your backend writing there, you can set the write to false for the public profiles.

Something like:

{ 
  "users_public": {
    "$uid": {
      ".read": "auth !== null",
      ".write": false
    }
  },
  "users": {
    "$uid": {
      ".read": "auth !== null && $uid === auth.uid",
      ".write": "auth !== null && $uid === auth.uid"
    }
  }
}

In case someone has the situation where a re-auth is needed, here's what I to do as a workaround to force update user custom claims (after a server call which can update these for example) in addition to the onAuthStateChanged config:

  refreshToken = () => {
    const { firebase, dispatch } = this.props;
    firebase
      .auth()
      .currentUser.getIdTokenResult(true)
      .then((idTokenResult) => {
        firebase.reloadAuth();
        dispatch({ type: 'CLAIMS_UPDATE', payload: idTokenResult });
      });
  };

firebase.reloadAuth() is needed to sync the new token in redux (useful if your server reads custom claims through the jwt token)

It's so important question because I don't actually know how to implement routing with user custom role?

Maybe it will be HOC or not?

@salmazov There is info about how to handle roles in the roles section of the docs, it just uses RTDB to store the profile not custom claims, which is what I believe the OP was looking for. Storing roles using the method mentioned in the docs would work for storing the profile in Firestore (useFirestoreForProfile config option enable in react-redux-firebase).

As noted before, I personally like making a public/private profile setup where the role/permissions live in the private profile. This pattern also comes in nice if you want to build a user portal where you have all of your roles/permissions/users - I don't have to much experience in doing this with custom claims, but it seems like it would need to involve storing the roles/permissions of each user anyway.

@prescottprue and all,

Thanks for all the info and suggestions. I didn't find time yet to get into this as the project I would be implementing this for is on a development hold for the moment.

I was looking for a solution using custom claims, but I will also consider a private profile setup if that makes things easier. My data is already read-only by the client, since I direct all write operation from the client via a JSON RPC like API implemented in cloud functions.

@0x80 Great to know, thanks for the update.

@prescottprue can you elaborate more on the ways around keeping things in sync? I thought I would implement something like the client side implementation described in the docs, but I'm having a hard time doing so inside onAuthStateChanged config prop.

const provider = new firebase.auth.GoogleAuthProvider();
firebase.auth().signInWithPopup(provider)
.catch(error => {
  console.log(error);
});

let callback = null;
let metadataRef = null;
firebase.auth().onAuthStateChanged(user => {
  // Remove previous listener.
  if (callback) {
    metadataRef.off('value', callback);
  }
  // On user login add new listener.
  if (user) {
    // Check if refresh is required.
    metadataRef = firebase.database().ref('metadata/' + user.uid + '/refreshTime');
    callback = (snapshot) => {
      // Force refresh to pick up the latest custom claims changes.
      // Note this is always triggered on first call. Further optimization could be
      // added to avoid the initial trigger when the token is issued and already contains
      // the latest claims.
      user.getIdToken(true);
    };
    // Subscribe new listener to changes on that node.
    metadataRef.on('value', callback);
  }
});

I did set my cloud function to define custom as follows and it works and I think that (with appropriate server rules) should be enough to define roles. The only problem is that I'm not able to automatically refresh the initial token issued on sign up, which carries no claims, onAuthStateChanged.

// On sign up.
exports.processSignUp = functions.auth.user().onCreate(user => {
    const customClaims = {
      userRole: 'subscriber',
    }
    // Set custom user claims on this newly created user.
    return admin
      .auth()
      .setCustomUserClaims(user.uid, customClaims)
      .then(() => {
        // Update real-time database to notify client to force refresh.
        const metadataRef = admin.database().ref('metadata/' + user.uid)
        // Set the refresh time to the current UTC timestamp.
        // This will be captured on the client to force a token refresh.
        return metadataRef.set({ refreshTime: new Date().getTime() })
      })
      .catch(error => {
        console.log(error)
      })
})

@matchatype - looks like you are just attaching the callback directly to the Firebase instance instead of passing into the config of react-redux-firebase (not sure that is to blame, but something to note).

For the initial signup - if you are only attaching the listener after successful auth, it won't trigger a change event for the first write since that would happen after authing right? Seems like the metadata listener should be attached before auth change at all then it can refresh. If you open an issue with a full repro we can debug further though

Actually, I was passing the logic to my RRF config like this:

import { reactReduxFirebase, getFirebase } from 'react-redux-firebase'
import { applyMiddleware, createStore } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import { reduxFirestore, getFirestore } from 'redux-firestore'
import thunk from 'redux-thunk'

import firebaseApp from '../services/firebase'

import rootReducer from './reducers/rootReducer'

const middleware = [thunk.withExtraArgument({ getFirebase, getFirestore })]

let callback = null
let metadataRef = null

const rrfConfig = {
  attachAuthIsReady: true,
  firebaseStateName: 'firebase',
  onAuthStateChanged: (user, firebase, dispatch) => {
      // Remove previous listener.
      if (callback) {
        metadataRef.off('value', callback)
      }
      // On user login add new listener.
      if (user) {
        // Check if refresh is required.
        metadataRef = firebase.ref(`metadata/${user.uid}/refreshTime`)
        callback = () => {
          // Force refresh to pick up the latest custom claims changes.
          // Note this is always triggered on first call. Further optimization could be
          // added to avoid the initial trigger when the token is issued and already contains
          // the latest claims.
          user.getIdToken(true)
        }
        // Subscribe new listener to changes on that node.
        metadataRef.on('value', callback)
      }
  },
  useFirestoreForProfile: true,
  userProfile: 'users',
  presence: 'presence',
  sessions: 'sessions',
}

const store = createStore(
  rootReducer,
  composeWithDevTools(
    applyMiddleware(...middleware),
    reactReduxFirebase(firebaseApp, rrfConfig),
    reduxFirestore(firebaseApp),
  ),
)

export default store

It didn't work though, I got a type error: TypeError: firebase.database is not a function, even though firebase.ref looks like that... any idea why?

ref(path) {
  return firebase.database().ref(path);
}

I will try to setup a repo for that this weekend, and I was curious about what you said above on the ways around keeping things in sync.

@prescottprue I've added the ability to use custom claims. What is the downside of this approach?

https://github.com/joerex/react-redux-firebase/commit/ecdc308e552ad05baf1d38e1af8a161528625a90

@joerex I actually really like that approach! Totally open to a PR for adding logic to support it - we would just need some documentation around the requirement of using a real time database or firestore location for the refresh. Thanks for sharing 馃挴

Was this page helpful?
0 / 5 - 0 ratings