I have two classes of users - approved and unapproved. Both have authenticated with Firebase, but approved is allowed to read a broader class of documents than unapproved is. One way I could implement this is by storing a document for each user with their approved status, and then checking /users/{uid}
for status=="Approved"
in the Firestore rules as the read condition. However, this would double the amount of reads in my project and thus my costs with respect to reads - is there a more efficient way to do this? It isn't scalable to hardcode the UIDs into the security rules.
Firebase allows setting of custom claims within user authentication tokens, which is available to Firestore rules via the request.auth.token
map. Although it is not typical to use authentication tokens in an authorization context, because previously-issued authentication tokens will remain valid until their expiration in most systems (after which a refresh token can be used to obtain a new authentication token), you could potentially use this method along with a document listener to listen for updates to the user status in order to force a token refresh.
This listens to the user's document to set a claim via the admin API when the status
is changed:
import functions from 'firebase-functions';
import admin from 'firebase-admin';
export const updateUserStatusClaim = functions.firestore
.document('users/{userId}')
.onWrite((change, context) => {
const statusAfter = change.after.data().status;
if (change.before.data().status !== statusAfter) {
const userId = context.params.userId;
const auth = admin.auth();
auth.getUser(userId)
.then((user) => {
const updatedCustomClaims = Object.assign(
{},
user.customClaims,
{ status: statusAfter }
);
auth.setCustomUserClaims(userId, updatedCustomClaims)
.then(() => {
// Update another document for the client listener.
// This makes sure that the client does not refresh
// before the custom claims are set.
admin.firestore()
.doc(`userClaims/${userId}`)
.set(
{ status: statusAfter },
{ merge: true }
);
});
});
}
});
There is an asynchronous issue using this method for updating the claims:
status
.otherClaim
(does not contain the updated status
).The user's custom claims would not contain the updated status
, but would contain the updated otherClaim
.
A better method would be to use a separate document for storage of the user's custom claims, along with another document for clients to listen to. Even this method may have asynchronous issues, though:
status
.otherClaim
(and status
).The user's custom claims would not contain the updated otherClaim
, but would contain the updated status
.
This listens to the userClaims
document for changes. This document is updated only after the custom user claims have been set by the Cloud Functions document listener:
import firebase from 'firebase';
// assuming you have already called firebase.initializeApp()
const auth = firebase.auth();
const firestore = firebase.firestore();
let uid = undefined;
let userClaimsUnsubscriber = undefined;
auth.onAuthStateChanged((user) => {
if (
userClaimsUnsubscriber !== undefined &&
(user === null || user.uid !== uid)
) {
if (user === null) {
uid = undefined;
}
userClaimsUnsubscriber();
userClaimsUnsubscriber = undefined;
}
if (user !== null && userClaimsUnsubscriber === undefined) {
uid = user.uid;
userClaimsUnsubscriber = firestore
.doc(`userClaims/${uid}`)
.onSnapshot({
next: (snapshot) => {
// Force the client to refresh the token.
// In this example, we don't really care about
// the returned Promise.
user.getIdToken(true);
}
});
}
});
As mentioned in a comment to the original post, there is no guarantee that a client will adhere to refreshing their token when the userClaims
document is updated. So, if a user is moved from approved
to unapproved
status, they may still have access to approved user status functionality until their token naturally expires.