Skip to content

bobby_dreamer

Firebase Server side authentication

firebase, nodejs, javascript5 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,

  1. All the application logic is in the client side and its public
  2. Even the templating is being done in client side because its possible with handlebars or pure javascript but too much coding but got used with it.

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,

  1. Setup firebase authentication.
    • Email and Password
    • Google
  2. Public and Authenticated Routes
    • Authenticated routes (If not logged in, page will be redirected to login page)
    • Public routes available to all users(unregistered users as well)
  3. Premium content will be showed only to certain registered users this is using custom claims.

# 1. Setup firebase authentication.

Here is the flow,

  1. In the client side javascript add the below. This indicates that the state will only be stored in memory and will be cleared when the window or activity is refreshed. We are going to be using httpOnly cookies which do not persist any state client side.
    1firebase.auth().setPersistence(firebase.auth.Auth.Persistence.NONE);
  2. User signs-in and once the authentication is successful, the auth state will change and this function firebase.auth().onAuthStateChanged gets triggered, here we will be able to generate the ID Tokens via firebaseUser.getIdToken()
  3. Pass the generated ID Token in the body of the POST method to route /sessionLogin
  4. In the route, generate the session cookie __session via admin.auth().createSessionCookie

    Managing Cache

    • 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.
  5. Going forward on each request to authenticated route, cookie __session verified and appropriate content will be rendered.

Login

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.

FBAuth.js
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 now
8
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.

index.js
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.

__session cookie

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.

displaying all the cookies

# 2. Public and Authenticated Routes

Handling Authenticated routes

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.

fn_isAuthenticated.js
1const admin = require('firebase-admin');
2
3/** Initializations
4**********************************************************/
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/** Functions
19**********************************************************/
20
21/**
22 * checks whether the user is authenticated or not
23 * @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 request
26 */
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 variables
36 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});
Handling Public routes

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});

# 3. Premium content

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.

  • admin : True, means user has admin rights
  • premium : True, means user can view premium content

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 test
6 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.

From
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.

User Management

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.

um.js
1document.addEventListener('DOMContentLoaded', event => {
2
3 /* Initializations
4 ***************************************************************/
5 firebase.auth().setPersistence(firebase.auth.Auth.Persistence.NONE);
6
7 /* Functions
8 ***************************************************************/
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 property
39 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 property
43 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,

index.js
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,

  • Logout and Login (reAuthenticate)
  • Refresh the ID Token forcefully by currentUser.getIdToken(true) in the client side
    • In my case, this is not possible as i am signing-out after successful login from the client side.

Option left is to reAuthenticate

index.js
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.

fb_functions.js
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,

  1. How to setup custom claims ?
  2. How to refresh the ID Token to propagate the claim change ?

Now, on displaying the premium content, all one have to do is this,

index.js
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 else
8 res.render('non-premium.ejs', { title: "signup for premium - Sushanth Tests"})
9});

Non-Premium user view

non-premium.ejs - Non-Premium user view

Premium user view

premium.ejs - 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,

  1. Having a node user node in firebase and update it whenever a claim is updated.
  2. Have a listener at client which listens to 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.

# References