— firebase, nodejs, javascript — 5 min read
I am so much comfortable with using firebase client side authentication and doing things on the client side basically because i haven't tried much on the server side. My server side is very simple. After a while, when looking at the code, i found out, there are a few disadvantages i have found in having code at the Firebase client-side,
Here, i will be building a simple firebase app which will be doing majority of work at the server side like SSR(Server Side Rendering) and SSA(Server Side Authentication) and below are some of the items, i am planning to explore,
Here is the flow,
httpOnly
cookies which do not persist any state client side.1firebase.auth().setPersistence(firebase.auth.Auth.Persistence.NONE);
firebase.auth().onAuthStateChanged
gets triggered, here we will be able to generate the ID Tokens via firebaseUser.getIdToken()
/sessionLogin
__session
via admin.auth().createSessionCookie
- When using Firebase Hosting together with Cloud Functions or Cloud Run, cookies are generally stripped from incoming requests.
- Only the specially-named __session cookie is permitted to pass through to the execution of your app.
__session
verified and appropriate content will be rendered.
Once we are successfully logged-in via Firebase Client side authentication, we generate the ID Token as highlighted beblow and make a POST request to server to route /sessionLogin
with ID Token in the body.
1firebase.auth().onAuthStateChanged(firebaseUser => {2 3 $("body").fadeIn("fast"); 4 5 if(firebaseUser){6 firebaseUser.getIdToken().then(function(idToken) { 7 console.log(idToken); // It shows the Firebase token now8
9 return fetch("/sessionLogin", {10 method: "POST",11 headers: {12 Accept: "application/json",13 "Content-Type": "application/json",14 },15 body: JSON.stringify({ idToken }),16 }); 17 }).then( response => {18 // No longer need to be logged in from client side as authentication is handled at server side 19 firebase.auth().signOut(); 20 if(!response.ok){21 console.log(response);22 throw new Error(response.status+", "+response.statusText);23 }else{24 window.location.assign("/"); 25 }26 }).catch( error => {27 // window.location.assign("/login"); 28 console.log(error);29 }); 30 }31 });
In the server, we receive the ID Token in the request body and we create a firebase __session
cookie and set it.
1app.post("/sessionLogin", (req, res) => {2 const idToken = req.body.idToken.toString();3 console.log(`In ${req.path}, idToken=${idToken}`);4 console.log(`In ${req.path}, req.cookies=${JSON.stringify(req.cookies)}=`);5 console.log(`In ${req.path}, req.headers=${JSON.stringify(req.headers)}=`);6 // console.log(`In ${req.path}, req.body=${JSON.stringify(req.body)}=`);7
8 // Set session expiration to 5 days.9 const expiresIn = 60 * 60 * 24 * 5 * 1000; 10
11 admin.auth().createSessionCookie(idToken, { expiresIn }).then( (sessionCookie) => {12 console.log(`In ${req.path} : And in createSessionCookie()` );13 const options = { maxAge: expiresIn, httpOnly: true, secure: true };14 res.cookie("__session", sessionCookie, options);15 res.end(JSON.stringify({ status: "success" }));16 }, (error) => {17 console.log(`Error=${error}`);18 res.status(403).send("UNAUTHORIZED REQUEST!");19 }20 );21});
In the below picture, you can see a check mark under httpOnly
for __session
.
When the HttpOnly flag is used, JavaScript will not be able to read the cookie in case of XSS exploitation
In the above image, we see 3 cookies and only __session
has httpOnly
attribute checked. When we try to display all the cookies below, you can notice only __session
is not displayed.
The function isAuthenticated
checks whether the user is authenticated or not by retreiving the __session
cookie. This function behaves as a middleware but in ExpressJS, its a route handler which is executed before the route.
One additional thing being done in the below code is res.locals.fbUser = fbUser
. By doing this we are able to pass the fbUser data to the route.
1const admin = require('firebase-admin');2
3/** Initializations4**********************************************************/5/* Adding the if(condition) as i got the below error, after separating this function from index.js,6 * ! Error: The default Firebase app already exists. This means you called initializeApp() more than once without providing an app name as the 7 * second argument. In most cases you only need to call initializeApp() once. But if you do want to initialize multiple apps, pass a second argument to initializeApp() to give each app a unique name.8 * -- initializeApp() is called in the index.js first and its initialized there first. 9*/ 10var serviceAccount = require("../secrets/api-project-333122111111-testing-server-authentication.json");11if (!admin.apps.length) {12 admin.initializeApp({13 credential: admin.credential.cert(serviceAccount),14 databaseURL: "https://api-project-333122111111.firebaseio.com"15 }); 16}17
18/** Functions19**********************************************************/20
21/**22 * checks whether the user is authenticated or not23 * @param {!Object} req The expressjs request.24 * @param {!Object} res The expressjs response.25 * @param {!Object} next calls the next route handler in line to handle the request26 */27function isAuthenticated(req, res, next){28 try {29 var sessionCookie = req.cookies.__session || "";30 console.log(`In isAuthenticated() : sessionCookie=${sessionCookie}`);31 admin.auth().verifySessionCookie(sessionCookie, true/** checkRevoked **/).then( (fbUser) =>{32 console.log(`In isAuthenticated() : token verified`);33 console.log(`In isAuthenticated() : ${fbUser.uid}, ${fbUser.email}, ${fbUser.auth_time}`);34 console.log(`In isAuthenticated() : ${JSON.stringify(fbUser)}`);35 res.locals.fbUser = fbUser; //Passing variables36 next();37 }).catch(function(error){38 console.log("In isAuthenticated() ------------------------------------------------");39 console.log(error);40 res.clearCookie('__session');41 console.log("In isAuthenticated() : Will be redirected to /login -----------------")42 res.redirect('/login');43 });44 }catch (err)45 {46 console.log(err);47 } 48};49
50module.exports = {isAuthenticated} ;
In routes, isAuthenticated
is called like below
1const fn = require('./helper-functions/fn_isAuthenticated.js');2
3app.get('/feedback', fn.isAuthenticated, async function(req, res){4 console.log(`In ${req.path}, user=${res.locals.fbUser.uid}`);5
6 let fbUser = res.locals.fbUser;7 res.render('feedback.ejs', { title: "feedback - Sushanth Tests", uid:fbUser.uid, email:fbUser.email})8});
Here actually, there is nothing being done. When this route is called public.ejs
will be rendered and shown.
1app.get('/public', function(req, res){2 console.log(`In ${req.path}`);3 res.render('public.ejs', { title: "public - Sushanth Tests"})4});
Here, basically we are going to use custom claims which are attributes that can be added to user accounts in Firebase. So, whenever we enquire about user account details from Firebase, along with user details, you will also get these custom claims details. You might think instead of creating a new code called users
in firebase, we can use custom claims to add all the necessary user data like name, address, mobile-no, email, image-url, etc... But, you see custom claims data cannot exceed 1k bytes. So, its recommended to use it for user access controls. Also firebase security rules can access these custom claims, so by using the combination of firebase security rules and custom claims, you can restrict specific content to users.
In my example, i am adding two custom attributes having boolean values and in the route, i check the custom claims, what role user has and render page based on that.
In this example, i am not doing anything in the firebase security rules
Here, i am setting up the initial values to work on(one time run). Its just a simple route by calling it, it updates custom claims to the user details.
1app.get('/usermanagement', fn.isAuthenticated, async function(req, res){2 console.log(`In ${req.path}, user=${res.locals.fbUser.uid}`);3 console.log(`In ${req.path}, uid=${res.locals.fbUser.uid}, email=${res.locals.fbUser.email}, auth_time=${res.locals.fbUser.auth_time}`);4
5 //First Claim test6 admin.auth().setCustomUserClaims(fbUser.uid, {admin:true, premium:true}).then(()=> {7 console.log(`In ${req.path}, Setting claims success`);8 });9
10 res.render('usermanagement.ejs', { title: "User Management - Sushanth Tests", allUsers})11});
Next time, when you refresh any authenticated page or viewing user details, you can see the custom claim values. Here i have added below line in fn_isAuthenticated.js
to see the user details. So, when isAuthenticated()
middleware function is executed, it shows the user details in the console.
1console.log(`In isAuthenticated() : ${JSON.stringify(fbUser)}`);2
3# Output (end of first line, you can see the claims)4In isAuthenticated() : {"iss":"https://session.firebase.google.com/api-project-333122111111","admin":true,"premium":true,"aud":"api-project-333122111111","auth_time":1610715739,"user_id":"Xygc22eGS2VlaruxiqWJ5UUggsz2","sub":"Xygc22eGS2VlaruxiqWJ5UUggsz2","iat":1610715742,"exp":1611147742,"email":"x@y.com","email_verified":false,"firebase":{"identities":{"email":["x@y.com"]},"sign_in_provider":"password"},"uid":"Xygc22eGS2VlaruxiqWJ5UUggsz2"}
To work custom claims in managing users, i have created a page like below and here using the toggle button we can grant and revoke access to users.
Below is the route for the user management page. Function getAllUsers()
gets all the users, their details with claims and displayed as in above image. Later user you see the route /usermanagement
which calls this function.
1/* Filename : fb_functions.js */2/** Gets all the users (1000 MAX) from Firebase auth.3***************************************************************/4const getAllUsers = () => {5 const maxResults = 20; // optional arg.6 7 return admin.auth().listUsers(maxResults).then((userRecords) => {8 let allUsers = [];9 console.log(JSON.stringify(userRecords));10 userRecords.users.forEach((user) => allUsers.push(user));11 return allUsers;12 }).catch((error) => {13 console.log(`In getAllUsers(), ${error}`);14 return [];15 })16};17
18/* Filename : index.js */19const fn = require('./helper-functions/fn_isAuthenticated.js');20const fb = require('./helper-functions/fb_functions.js');21
22app.get('/usermanagement', fn.isAuthenticated, async function(req, res){23 console.log(`In ${req.path}, user=${res.locals.fbUser.uid}`);24 console.log(`In ${req.path}, uid=${res.locals.fbUser.uid}, email=${res.locals.fbUser.email}, auth_time=${res.locals.fbUser.auth_time}`);25
26 let allUsers = await fb.getAllUsers();27 console.log(`In ${req.path}, allUsers=${JSON.stringify(allUsers)}`);28
29 res.render('usermanagement.ejs', { title: "User Management - Sushanth Tests", allUsers})30});
This is the client side script of the user management page.
1document.addEventListener('DOMContentLoaded', event => {2
3 /* Initializations4 ***************************************************************/5 firebase.auth().setPersistence(firebase.auth.Auth.Persistence.NONE);6
7 /* Functions8 ***************************************************************/9 async function updateClaims(uid, adminSwitch, premiumSwitch){10
11 let response = await fetch("/updateClaims", {12 method: "POST",13 headers: {Accept: "application/json", "Content-Type": "application/json"},14 body: JSON.stringify({uid, adminSwitch, premiumSwitch }),15 16 }).then(response => {17 if (!response.ok) {18 throw new Error(response.message);19 }20 return response.json();21 }).then(data => {22 console.log("Response : ",data);23 }).catch((error) => {24 console.error('Error:', error);25 });26 }27
28 function getSwitchValues(){29 let classname = $(this).attr('class');30 console.log(classname);31
32 let uid = $(this).attr('content');33 console.log(uid);34
35 let cbAdminSwitch=null, cbPremiumSwitch=null;36 if(classname =="adminSwitch"){37 cbAdminSwitch = $(this).prop('checked');38 //This goes up(closest) and next() and finds the class and checks the property39 cbPremiumSwitch = $(this).closest('td').next().find('.premiumSwitch').prop('checked'); 40 }else{41 cbPremiumSwitch = $(this).prop('checked');42 //This goes up(closest) and next() and finds the class and checks the property43 cbAdminSwitch = $(this).closest('td').prev().find('.adminSwitch').prop('checked');44 }45
46 console.log(cbAdminSwitch);47 console.log(cbPremiumSwitch);48 updateClaims(uid, cbAdminSwitch, cbPremiumSwitch);49 }50
51 //jQuery will automatically invoke function with the proper context set meaning no need to pass $(this).52 $('.page').on('click', '.adminSwitch', getSwitchValues);53 $('.page').on('click', '.premiumSwitch', getSwitchValues);54
55});
Below is the route that handles the POST request and updates the firebase custom claims, when switch is toggled,
1app.post("/updateClaims", fn.isAuthenticated, async (req, res) => {2 console.log(`In ${req.path}, req.headers=${JSON.stringify(req.headers)}=`);3 console.log(`In ${req.path}, user=${res.locals.fbUser.uid}`);4
5 const uid = req.body.uid.toString();6 const adminSwitch = (req.body.adminSwitch.toString()) == "true" ? true : false;7 const premiumSwitch = (req.body.premiumSwitch.toString()) == "true" ? true : false;8 console.log(`In ${req.path}, uid=${uid}, adminSwitch=${adminSwitch}, premiumSwitch=${premiumSwitch}`); 9
10 let claims = {admin:adminSwitch, premium:premiumSwitch};11 console.log(claims);12 await admin.auth().setCustomUserClaims(uid, claims).then(()=> {13 console.log(`In ${req.path}, Setting claims success`);14 });15 16 res.writeHead(200, {"Content-Type": "application/json"});17 // res.json({status:200,redirect:'Success! Updated Claims'});18 res.end(JSON.stringify({ ok:true, status:200,message:'Success! Updated Claims' }));19});
One of the main things to remember here is, there is a relationship between Firebase ID Token and Custom claims. So when you are signing-in to the app, a ID Token is generated via firebaseUser.getIdToken()
and using the token __session
cookie is created and for all authenticated routes, this cookie is used for verification. ID Token and __session
has the same value.
When your custom claims change, it doesn't get reflected/propagated right away as this ID Tokens and claims are connected. So to propagate the change, user needs to refresh the token, which can be done by one of the following methods,
currentUser.getIdToken(true)
in the client sideOption left is to reAuthenticate
1app.get('/signout', fn.isAuthenticated, async function(req, res){2 console.log(`In ${req.path}, user=${res.locals.fbUser.uid}`);3
4 await fb.refreshToken(req, res);5 res.redirect('/login');6});
Main code here is just the highlighted one.
1/** Calls the revocation API to revoke all user sessions nd force new login.2***************************************************************************/3/**4 * @param {!Object} req The expressjs request.5 * @param {!Object} res The expressjs response.6 */7async function refreshToken(req, res){8 try {9 var sessionCookie = req.cookies.__session || "";10 console.log(`In refreshToken() : sessionCookie=${sessionCookie}`);11
12 let fbUser = await admin.auth().verifySessionCookie(sessionCookie, true/** checkRevoked **/).then((fbUser) =>{13 console.log(`In refreshToken() : verifySessionCookie : cookie verified`);14 console.log(`In refreshToken() : verifySessionCookie : ${fbUser.uid}, ${fbUser.email}, ${fbUser.auth_time}`);15 console.log(`In refreshToken() : verifySessionCookie : ${JSON.stringify(fbUser)}`);16 return fbUser; 17 }).catch(function(error){18 console.log("In refreshToken() ------------------------------------------------");19 console.log(error);20 res.clearCookie('__session');21 console.log("In refreshToken() : Will be redirected to /login -----------------")22 res.redirect('/login');23 });24 let uid = fbUser.uid;25 console.log(`In refreshToken() : uid=${uid}`);26
27 // Revoke all refresh tokens for a specified user for whatever reason.28 // Retrieve the timestamp of the revocation, in seconds since the epoch.29 await admin.auth().revokeRefreshTokens(uid);30
31 fbUser = await admin.auth().getUser(uid).then( fbUser =>{32 let rt = new Date(fbUser.tokensValidAfterTime).getTime() / 1000;33 console.log(`In refreshToken() : revokeRefreshTokens : Tokens revoked at ${rt}`);34 return fbUser; 35 });36 }catch (err)37 {38 console.log("In refreshToken() ------------------------------------------------");39 console.log(err);40 res.clearCookie('__session');41 console.log("In refreshToken() : Will be redirected to /login -----------------")42 res.redirect('/login');43 } 44};
So far what we have seen is,
Now, on displaying the premium content, all one have to do is this,
1app.get('/premium', fn.isAuthenticated, async function(req, res){2 console.log(`In ${req.path}, user=${res.locals.fbUser.uid}`);3
4 let fbUser = res.locals.fbUser;5 if(fbUser.premium)6 res.render('premium.ejs', { title: "premium - Sushanth Tests"})7 else8 res.render('non-premium.ejs', { title: "signup for premium - Sushanth Tests"}) 9});
Non-Premium user view
Premium user view
Here we are using simple approach to revoke the token asking user to reAuthenticate themselves by click logout. We can do this automatically as well, by setting up some logics like,
user
node in firebase and update it whenever a claim is updated. user
node or in middleware like isAuthenticated()
which check if the ID token is revoked and redirect the user to login screen or open up a modal box asking userid and password again to reAuthenticate. Its all upto to the implementer.