Firestore token doesn't update in security rules after setting custom claim - firebase

TLDR;
Setup firestore security rules based on custom claim.
Cloud firestore user is created by phone auth.
Cloud function triggers on create user and adds a custom claim role- admin. An entry is updated in realtime database to indicate the claim update.
Listen to updates to realtime database in the client and call user.getIdToken(true); after custom claim is updated.
Able to see the added custom claim in the code.
Unable to read a document in firestore due to missing permission(custom claim).
Refresh the browser page, able to read the doc now.
I have a cloud function that adds a custom claim role - admin on user create.
exports.processSignUp = functions.auth.user().onCreate((user) => {
const customClaims = {
role: 'admin',
};
// 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);
});
});
I listen to change events in realtime database to detect updates to custom claim for a user.
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 have the following security rules on cloud firestore.
service cloud.firestore {
match /databases/{database}/documents {
match /{role}/{document=**} {
allow read: if request.auth != null &&
request.auth.token.role == role;
}
}
}
After the user is created, my cloud function triggers and adds the custom claim role = admin.
As a result of user.getIdToken(true); the token is refreshed on my client and I am able to see the set custom claim.
When I try to get a document that the user should be able to read, I get a permission denied by cloud firestore security rules.
When I refresh the browser page, I am able to read the documents in the path.
I am expecting that to be able to access the firebase doc without having to refresh the browser. Is this a possibility?
Can someone please tell me what is wrong with my approach/expectation?

It can take longer than you think for the token to be propagated. I use custom claims extensively - what I have found to work is to setup .onIdTokenChanged() to track uid & token updates, and then explicitly call .getIdTokenResult(true) to update my local token. Only after both are complete can you make customClaim secured calls to Firestore and/or RTDB.

Related

Is there a way to generate a firebase email verification link before a user is actually signed up?

I am currently implementing a MFA system with Firebase Authentication & Google Authenticator.
Since my users are not allowed to authenticate with a non-verified email address, I'd like to prevent them from signing-in if their Firebase Authentication email_verified is set to false. To do that, I am using Google Cloud Identity Provider blocking functions, this works perfectly.
However, when it comes to the registration beforeCreate blocking function hook, I can't find a way to generate an email verification link for the user currently being created, the documentation says:
Requiring email verification on registration The following example
shows how to require a user to verify their email after registering:
export.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
const locale = context.locale;
if (user.email && !user.emailVerified) {
// Send custom email verification on sign-up.
return admin.auth()
.generateEmailVerificationLink(user.email)
.then((link) => {
return sendCustomVerificationEmail(
user.email, link, locale
);
});
}
});
export.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
if (user.email && !user.emailVerified) {
throw new gcipCloudFunctions.https.HttpsError(
'invalid-argument', `"${user.email}" needs to be verified before access is granted.`);
}
});
However, as far as I understand, generateEmailVerificationLink() can only be called to generate email verification link of an existing Firebase Authentication user. At this stage (while running beforeCreate blocking function), the user is not created yet.
Now I am wondering, I am missing something or is the Google documentation wrong?
No.
User data is created upon registration in the database.
Then, you may send an Email-Verification with a link automatically.
This Email-Verification just updates the field emaiVerified of said user data.
If you want to prevent users with unverified Emails from logging in, you need to adjust your Login page and check whether emaiVerified is true.
Important: Google will sign in a user right upon registration whether the email is verified or not, as this is the expected behavior from the perspective of a user. Email verification is ensured on the second, manual login.
(Also, please do not screenshot code.)
You can let a user sign in via email link at first, and call firebase.User.updatePassword() to set its password.
I am using Angular-Firebase, this is the logic code.
if (this.fireAuth.isSignInWithEmailLink(this.router.url)) {
const email = this.storage.get(SIGN_IN_EMAIL_KEY) as string;
this.storage.delete(SIGN_IN_EMAIL_KEY);
this.emailVerified = true;
this.accountCtrl.setValue(email);
from(this.fireAuth.signInWithEmailLink(email, this.router.url)).pipe(
catchError((error: FirebaseError) => {
const notification = this.notification;
notification.openError(notification.stripMessage(error.message));
this.emailVerified = false;
return of(null);
}),
filter((result) => !!result)
).subscribe((credential) => {
this.user = credential.user;
});
}
const notification = this.notification;
const info = form.value;
this.requesting = true;
form.control.disable();
(this.emailVerified ? from(this.user.updatePassword(info.password)) : from(this.fireAuth.signInWithEmailLink(info.account))).pipe(
catchError((error: FirebaseError) => {
switch (error.code) {
case AUTH_ERROR_CODES_MAP_DO_NOT_USE_INTERNALLY.POPUP_CLOSED_BY_USER:
break;
default:
console.log(error.code);
notification.openError(notification.stripMessage(error.message));
}
this.requesting = false;
form.control.enable();
return of(null);
}),
filter((result) => !!result)
).subscribe((result: firebase.auth.UserCredential) => {
if (this.emailVerified) {
if (result.user) {
notification.openError(`注册成功。`);
this.router.navigateByUrl(this.authService.redirectUrl || '');
} else {
notification.openError(`注册失败。`);
this.requesting = false;
form.control.enable();
}
} else {
this.storage.set(SIGN_IN_EMAIL_KEY, info.account);
}
});
Mate, if database won't create a new user using his email and password, and you send him email verification which will create his account, how the heck database will know his password? If it didn't create his account in the first step? Stop overthinking and just secure database using rules and routes in application if you don't want user to read some data while he didn't confirm email address.
It is that simple:
match /secretCollection/{docId} {
allow read, write: if isEmailVerified()
}
function isEmailVerified() {
return request.auth.token.email_verified
}
I think the blocking function documentation is wrong.
beforeCreate: "Triggers before a new user is saved to the Firebase Authentication database, and before a token is returned to your client app."
generateEmailVerificationLink: "To generate an email verification link, provide the existing user’s unverified email... The operation will resolve with the email action link. The email used must belong to an existing user."
Has anyone come up with a work around while still using blocking functions?
Using firebase rules to check for verification isn't helpful if the goal is to perform some action in the blocking function, such as setting custom claims.

Update document with new userId after user authenticated for the second time

I have the following use case that is working as expected:
new user arrives on website
user is given an user.uid from anonymous sign in from firebase
user creates a document having as userId reference the aforementioned user.uid
in the very same page user is invited to sign in otherwise the document will be lost
user logs in and finds in is account the document
WIN!
Now I have a use case that is not working as expected:
returning user with session expired or from a different browser arrives on website
user is given an user.uid from anonymous sign in from firebase
user creates a document having as userId reference the aforementioned user.uid
in the very same page user is invited to sign in otherwise the document will be lost
user logs in and doens't find in is account the document
DIDN'T WIN THIS TIME :(
I configured firebase auth with the following configuration:
const uiConfig = {
signInFlow: 'popup',
autoUpgradeAnonymousUsers: true,
signInOptions: [
firebase.auth.GoogleAuthProvider.PROVIDER_ID,
],
callbacks: {
signInFailure: (error: any) => {
if (error.code != 'firebaseui/anonymous-upgrade-merge-conflict') {
return Promise.resolve();
}
var cred = error.credential;
return firebase.auth().SignInAndRetrieveDataWithCredential(cred);
}
},
};
So, as you can see, the issue is that, the first time, autoUpgradeAnonymousUsers create a new userId with the anonymous user id and everything is fine, but of course the second time doesn't work anymore.
How should I solve this problem given that in my security rules I want to create a check that userId cannot be updated AND only a request with the same userId can see the document?
security rules:
allow create: if request.auth.uid != null
allow read: if request.auth.uid == resource.data.userId
&& request.auth.uid != null
allow update: if request.auth.uid == request.resource.data.userId && resource.data.userId == request.resource.data.userId && request.auth.uid != null
Thank you.
The problem is you can not create a new user with the same credentials. If the user logs in he looses the data from the anonymous sign in.
You have to save the data from the anonymous user locally and after the user logs in you have to copy the data to the current user. You should also deleting the anonymous account.
I have found this example which uses the Firebase realtime database to save the user data.
https://github.com/firebase/firebaseui-web#upgrading-anonymous-users
// signInFailure callback must be provided to handle merge conflicts which
// occur when an existing credential is linked to an anonymous user.
signInFailure: function(error) {
// For merge conflicts, the error.code will be
// 'firebaseui/anonymous-upgrade-merge-conflict'.
if (error.code != 'firebaseui/anonymous-upgrade-merge-conflict') {
return Promise.resolve();
}
// The credential the user tried to sign in with.
var cred = error.credential;
// If using Firebase Realtime Database. The anonymous user data has to be
// copied to the non-anonymous user.
var app = firebase.app();
// Save anonymous user data first.
return app.database().ref('users/' + firebase.auth().currentUser.uid)
.once('value')
.then(function(snapshot) {
data = snapshot.val();
// This will trigger onAuthStateChanged listener which
// could trigger a redirect to another page.
// Ensure the upgrade flow is not interrupted by that callback
// and that this is given enough time to complete before
// redirection.
return firebase.auth().signInWithCredential(cred);
})
.then(function(user) {
// Original Anonymous Auth instance now has the new user.
return app.database().ref('users/' + user.uid).set(data);
})
.then(function() {
// Delete anonymnous user.
return anonymousUser.delete();
}).then(function() {
// Clear data in case a new user signs in, and the state change
// triggers.
data = null;
// FirebaseUI will reset and the UI cleared when this promise
// resolves.
// signInSuccessWithAuthResult will not run. Successful sign-in
// logic has to be run explicitly.
window.location.assign('<url-to-redirect-to-on-success>');
});
}

dynamic keys of firebase custom claims in real-time database security rules

I add a dynamic json key to an user's custom claims and i want to use that key/value to allow user to write on a node.
Ex: Here is how i do it using the firebase admin sdk for nodejs.
// I get the user object
admin.auth().getUser(uid).then(userRecord => {
let claims = userRecord.customClaims; // current user claims
// I add the new key/value to the current claims object
// shop.id is obtained with DatabaseReference.push()
//Note: I prepend a 'B' to prevent a JSON key starting with '-'
claims[`B${shop.id}`] = someData[userRecord.uid] === 'manager' ? 1 : 0;
// I update the user claims
return admin.auth().setCustomUserClaims(userRecord.uid, claims);
});
In the realtime database security rules i have a node where i control the write permission with the following rule:
No other rule out of the mNode scope
{
"mNode":{
"$shopId":{
".read": "auth.uid!==null"
".write":"auth.token['B'+$shopId]===1"
}
}
}
On android i try to write on the path.
DatabaseReference ref = FirebaseDatabase.getInstance().getReference();
ref.child("mNode").child("theShopIdUsedInClaims").setValue(t)
.addOnCompleteListener(task -> {
if(task.isSuccessful()){
listener.onSuccess();
}else {
listener.onError(task.getException(),ref.toString());
}
});
The claims are successfully set to the user's token. I check them by 'stringifying' the user's claims object. But the write access is not granted.
Thank you for your help and sorry for my poor english.

Firebase Firestore - View Realtime Listeners & Unsubscribe Listeners on JavaScript Client SDK

How do you view active realtime listeners on a browser/client? Handle listen errors documentation indicates
After an error, the listener will not receive any more events
but I just wanted to validate / verify that my listener is unsubscribed after certain events.
Here's my code snippet. There is a cart field in my user document that stores an orderId pointing to a document in a separate orders collection. On signin, I want to listen to the returned user.uid document in my users collection. If there is an order in their cart (e.g. cart: h4the-cart-uid-7rhksjn), I want to listen to that specific order - if not (e.g. null or "", detach any listeners on that order (the crux of my problem, old orderId is gone). My issue is unsubscribing the listeners so that I'm not throwing insufficient privilege / not authorized errors bcs the listener is still active after user empties their cart, or user signed out.
It would be nice if I could just say unsubscribe to the parent collection...
var userDocListener;
var orderDocListener;
firebase.auth().onAuthStateChanged(function (user) {
if (user) {
userDocListener = function(){
return firebase.firestore().collection("users").doc(user.uid).onSnapshot(function (userDoc) {
if(userDoc.exists){
if(userDoc.data().cart == null || !userDoc.data().cart){
//user cart empty HOW TO UNSUBSCRIBE W/O THE ORDER ID?
cartBadgeDisplayHandler();
if(window.location.pathname === "/account" || window.location.pathname === "account"){
accountPageCartTabOrderNoOrderView();
}
orderDocListener();
} else {
orderDocListener = function () {
return firebase.firestore().collection("orders").doc(userDoc.data().cart).onSnapshot(function (orderDoc) {
cartBadgeDisplayHandler(orderDoc);
});
}
orderDocListener();
}
}
});
}
userDocListener();
} else {
// no user HOW TO UNSUBSCRIBE W/O THE USER UID?
userDocListener();
}
});
There is no API that lists active listeners. If you need such a list, you will need track it yourself.

How to ensure Anonymous login with Firebase?

I'm working on a React Native application which use Firebase Auth uid to identify individual user instances.
My initial implementation called signInAnonymously on every starting up and it returned non-persistent uid.
Referring to Anonymous user in Firebase, my code became like this. it works as expected but still unclear why this code is correct.
static ensureLogin() {
return new Promise((resolve, reject) => {
firebase.auth().onAuthStateChanged((user) => {
if (user) {
resolve(user)
} else {
firebase.auth().signInAnonymously()
.catch(function(error) {
reject()
})
}
})
}
To answer this question, let's check details in this official document.
https://firebase.google.com/docs/auth/web/manage-users
It has this example code:
firebase.auth().onAuthStateChanged(function(user) {
if (user) {
// User is signed in.
} else {
// No user is signed in.
}
});
The latter comment line says "No user is signed in." instead of "User is signed out.".
It implies that it is triggered not only by user's sign in/out actions.
It also says below.
By using an observer, you ensure that the Auth object isn't in an
intermediate state—such as initialization—when you get the current
user.
This means that 2 facts.
Auth object has internal states
onAuthStateChanged observes the state of Auth object
So, we should notice that onAuthStateChanged can be triggered by any status change of Auth object including initialization! And calling signInAnonymously after init is nothing strange.
Once signInAnonymously in else block is called, it re-triggers onAuthStateChanged after signing in.
That's why the code is correct.

Resources