I'm trying to use Firebase custom claims to protect content for my users, but the first time a user signs up and is redirected to /protectedpage
, they cannot view the page because their claim is not set. If they log out and log back in, everything works properly.
Signup Flow
- User signs up with email and password
- A user document is created in a users collection in Firestore
- The user is redirected to
/protectedpage
- Creation of the user document triggers a cloud function which assigns the custom claim
role=A
orrole=B
depending on the information in the user document.
In Javascript (React), it looks like this
Client side
// Create a new user with email and password
createUserWithEmailAndPassword(auth, formValues.email, formValues.password)
.then((userCredential) => {
// Signed in
const user = userCredential.user;
// Add a new document in collection "users"
setDoc(doc(db, "users", user.uid), {
account_type: formValues.account_type,
full_name: formValues.full_name,
});
// Send email verification
sendEmailVerification(userCredential.user)
.then(() => {
// Redirect to home page
router.push('/protectedpage');
})
.catch((error) => {
console.log("Error sending email verification", error.message);
});
})
.catch((error) => {
setFormError(error.message);
})
Server side
const functions = require('firebase-functions')
const { initializeApp } = require('firebase-admin/app');
const { getAuth } = require('firebase-admin/auth');
initializeApp();
// This function runs when a document is created in
// the users collection
exports.createUser = functions.firestore
.document('users/{userId}')
.onCreate(async (snap, context) => {
// Get an object representing the document
const doc = snap.data()
const userId = context.params.userId;
// Declare customClaims
let customClaims = {};
// Assign user role
if (doc.account_type == 'A') {
customClaims["role"] = "A"
} else if (doc.account_type == 'B') {
customClaims["role"] = "B"
} else {
functions.logger.info('A role could not be assigned to user:', doc)
response.send('Error: A role could not be assigned')
}
try {
// Set custom user claims on this newly created user.
await getAuth().setCustomUserClaims(userId, customClaims);
} catch (error) {
functions.logger.info(error);
}
return "OK"
})
By the time the user gets to /protectedpage
, his JWT does not have the custom claim.
Authorization
My authorization code is using a React context manager, and looks like this
import { createContext, useContext, useEffect, useState } from 'react'
import { onAuthStateChanged, signOut as authSignOut } from 'firebase/auth'
import { auth } from './firebase'
export default function useFirebaseAuth() {
const [authUser, setAuthUser] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const clear = () => {
setAuthUser(null)
setIsLoading(false)
}
const authStateChanged = async (user) => {
setIsLoading(true)
if (!user) {
clear()
return
}
// Use getIdTokenResult() to fetch the custom claims
user.getIdTokenResult()
.then((idTokenResult) => {
console.log("idTokenResult", idTokenResult)
setAuthUser({
uid: user.uid,
email: user.email,
role: idTokenResult.claims.role,
})
setIsLoading(false)
})
.catch((error) => {
console.log(error)
})
}
const signOut = () => authSignOut(auth).then(clear)
// Listen for Firebase Auth state change
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, authStateChanged)
return () => unsubscribe()
}, [])
return {
authUser,
isLoading,
signOut,
}
}
const AuthUserContext = createContext({
authUser: null,
isLoading: true,
signOut: async () => {},
})
export function AuthUserProvider({ children }) {
const auth = useFirebaseAuth()
return (
<AuthUserContext.Provider value={auth}>{children}</AuthUserContext.Provider>
)
}
export const useAuth = () => useContext(AuthUserContext)
If I change user.getIdTokenResult()
to user.getIdTokenResult(true)
, the user no longer has to sign out and sign back in to access the custom claim BUT
- They need to manually refresh the page to acquire the custom claim
- I think this is bad, as it's going to forcibly refresh the token on every page load ??
The Firebase docs seem to address this problem with some trickery involving "metadataRef" but I don't understand it exactly, as I think it's related to the Realtime database whereas I'm using Firestore.