Responding to my own post because I devised a solution for customizing MFA behavior in Firebase, so thought I'd post it here in case it helps anyone else. Still, if anyone from the Firebase team sees this, would be so helpful to know the status of Firebase Auth.
Some notes about this solution:
- It requires a backend for you to process the second factor challenge. Not a problem for us but if you're using Firebase with just the client-side Auth and things like Firestore, you'll need some other backend API, though this approach should also work even if you just use Firebase Functions for your backend.
- It should also work with all of the Security Rules in Firebase, so you can protect access to things like Storage and the DB, ensuring the user has entered the second factor.
- In addition to allowing you to customize the type of 2nd factor (SMS, phone call, email, TOTP, WebAuthn), it also allows you to do "remember device" functionality.
- While it requires to do a substantial amount of processing yourself, it keeps the major benefits of Firebase Auth, e.g. never storing passwords in your infrastructure, and never even having passwords cross your network.
The basis of this approach is that it depends on having a "second_factor"
custom claim on the JWT. That claim looks like this:
{
"second_factor": {
"type": "sms", // type can be whatever type of second factor you decide to support
"device_id": "some-guid-here", // ID of the device where the user validated the 2nd factor
"ts": 1234567 // timestamp of when the user validated the second factor
}
}
Since this custom claim will exist on the JWT for users who have passed the second factor, you can then write your Firebase security rules to essentially consider a JWT withOUT this custom claim as not logged in. E.g. you can write a Firebase security rule to not allow access to a particular Storage bucket unless the user has this second_factor custom claim.
The workflow for authenticating a user, and presenting the 2nd factor challenge, is as follows:
- On the client, log in the user as you would presently, e.g. const userCredentials = await firebase.auth().signInWithEmailAndPassword(email, password);
- Get the ID token, e.g. const idToken = userCredentials.user.getIdToken();, and send it up to your API backend.
- On your backend, the first thing you need to do is validate the token by using the Admin API, e.g. const decodedToken = auth.verifyIdToken(idToken);
- If the verification succeeds, at this point you can double check that the decodedToken does NOT have a second_factor custom claim (it shouldn't if the user followed the flow above). At this point it is up to you to present whatever 2nd factor challenge you want to the user (i.e. the backend will return basically a "CHALLENGE_REQUIRED" response that the front end can then use to show a challenge). For example, if you have saved the user's phone number previously, you can send down a masked phone number and let the user choose if they want an SMS or voice call (E.g. "Send code to XXX-XXX-XX12 by _ SMS or _ Phone Call").
- At this point you will have back and forth between the front end and backend to implement the challenge (e.g. send the phone call, and then present the user a screen where they can enter the 6 digit code, or just show the screen where they can enter a TOTP code). At all times you need to pass the idToken up to the server so that you can verify that "This user has logged in successfully, but hasn't yet finished the 2nd factor challenge". You are also responsible for things like rate limits.
- If the user passes the challenge, then on your backend:
- First, you want to pull out any persistent custom claims that may already be on the decodedToken. For example, we save role information on our users in Firebase as custom claims. All custom claims you previously saved on the user will be accessible as top-level properties on the DecodedIdToken interface. We always scope our custom claims under a single top-level property so it's easy to pull them out, e.g. decodedToken.my_company_custom_claims.
- You then want to add the second_factor custom claim as shown above, e.g. const myCustomClaims = decodedToken.my_company_custom_claims; myCustomClaims.second_factor = { ... info as shown above ... }; If you want to set the device_id you should get that e.g. from a cookie set on the client.
- You then create a custom token on the backend and return this as a response to the front end. Importantly you'll be passing the additionalClaims parameter:
const customToken = await auth.createCustomToken(decodedToken.uid, { my_company_custom_claims: myCustomClaims });
- At this point when you return the custom token to the front end, the front end uses it to log in now:
const userCredential = await firebase.auth().signInWithCustomToken(token); - The userCredential now has all the persisted custom claims of the user that first signed in, and importantly, it also has the second_factor claims.
At this point, whenever you validate a JWT, you should always check for the presence of the second_factor claims.
In addition, if you wanted to implement "remember device" functionality, so that the user needs to log in again but NOT be given a second factor challenge again from the same device, in step #4 above you can compare the device ID passed up in the request (again, just put this in a secure httpOnly cookie) to a list of remembered device IDs that you have stored for the user. If the device is a match and within the expiration date, then you can skip the workflow in step #5 and essentially just add your "second factor" claim like this in step #6: { "second_factor": { "type": "remembered_device", "device_id": "...", "ts": ... }}.
This is all obviously a non-trivial amount of work, but if you, like our team, are needing to do a cost/benefit analysis of migrating to another auth provider, the solution above at least gives you another option to compare.