Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Handling Firebase ID tokens on the client side with vanilla JavaScript

I am writing a Firebase application in vanilla JavaScript. I am using Firebase Authentication and FirebaseUI for Web. I am using Firebase Cloud Functions to implement a server that receives requests for my page routes and returns rendered HTML. I am struggling to find the best practice for utilizing my authenticated ID tokens on the client side to access protected routes served by my Firebase Cloud Function.

I believe I understand the basic flow: the user logs in, which means an ID token is sent to the client, where it is received in the onAuthStateChanged callback and then inserted into the Authorization field of any new HTTP request with the proper prefix, and then checked by the server when the user attempts to access a protected route.

I do not understand what I should do with the ID token inside the onAuthStateChanged callback, or how I should modify my client side JavaScript to modify the request headers when necessary.

I am using Firebase Cloud Functions to handle routing requests. Here is my functions/index.js, which exports the app method that all requests are redirected to and where ID tokens are checked:

const functions = require('firebase-functions')
const admin = require('firebase-admin')
const express = require('express')
const cookieParser = require('cookie-parser')
const cors = require('cors')

const app = express()
app.use(cors({ origin: true }))
app.use(cookieParser())

admin.initializeApp(functions.config().firebase)

const firebaseAuthenticate = (req, res, next) => {
  console.log('Check if request is authorized with Firebase ID token')

  if ((!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) &&
    !req.cookies.__session) {
    console.error('No Firebase ID token was passed as a Bearer token in the Authorization header.',
      'Make sure you authorize your request by providing the following HTTP header:',
      'Authorization: Bearer <Firebase ID Token>',
      'or by passing a "__session" cookie.')
    res.status(403).send('Unauthorized')
    return
  }

  let idToken
  if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
    console.log('Found "Authorization" header')
    // Read the ID Token from the Authorization header.
    idToken = req.headers.authorization.split('Bearer ')[1]
  } else {
    console.log('Found "__session" cookie')
    // Read the ID Token from cookie.
    idToken = req.cookies.__session
  }

  admin.auth().verifyIdToken(idToken).then(decodedIdToken => {
    console.log('ID Token correctly decoded', decodedIdToken)
    console.log('token details:', JSON.stringify(decodedIdToken))

    console.log('User email:', decodedIdToken.firebase.identities['google.com'][0])

    req.user = decodedIdToken
    return next()
  }).catch(error => {
    console.error('Error while verifying Firebase ID token:', error)
    res.status(403).send('Unauthorized')
  })
}

const meta = `<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link type="text/css" rel="stylesheet" href="https://cdn.firebase.com/libs/firebaseui/2.6.0/firebaseui.css" />

const logic = `<!-- Intialization -->
<script src="https://www.gstatic.com/firebasejs/4.10.0/firebase.js"></script>
<script src="/init.js"></script>

<!-- Authentication -->
<script src="https://cdn.firebase.com/libs/firebaseui/2.6.0/firebaseui.js"></script>
<script src="/auth.js"></script>`

app.get('/', (request, response) => {
  response.send(`<html>
  <head>
    <title>Index</title>

    ${meta}
  </head>
  <body>
    <h1>Index</h1>

    <a href="/user/fake">Fake User</a>

    <div id="firebaseui-auth-container"></div>

    ${logic}
  </body>
</html>`)
})

app.get('/user/:name', firebaseAuthenticate, (request, response) => {
  response.send(`<html>
  <head>
    <title>User - ${request.params.name}</title>

    ${meta}
  </head>
  <body>
    <h1>User ${request.params.name}</h1>

    ${logic}
  </body>
</html>`)
})

exports.app = functions.https.onRequest(app)

Her is my functions/package.json, which describes the configuration of the server handling HTTP requests implemented as a Firebase Cloud Function:

{
  "name": "functions",
  "description": "Cloud Functions for Firebase",
  "scripts": {
    "lint": "./node_modules/.bin/eslint .",
    "serve": "firebase serve --only functions",
    "shell": "firebase experimental:functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "dependencies": {
    "cookie-parser": "^1.4.3",
    "cors": "^2.8.4",
    "eslint-config-standard": "^11.0.0-beta.0",
    "eslint-plugin-import": "^2.8.0",
    "eslint-plugin-node": "^6.0.0",
    "eslint-plugin-standard": "^3.0.1",
    "firebase-admin": "~5.8.1",
    "firebase-functions": "^0.8.1"
  },
  "devDependencies": {
    "eslint": "^4.12.0",
    "eslint-plugin-promise": "^3.6.0"
  },
  "private": true
}

Here is my firebase.json, which redirects all page requests to my exported app function:

{
  "functions": {
    "predeploy": [
      "npm --prefix $RESOURCE_DIR run lint"
    ]
  },
  "hosting": {
    "public": "public",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "function": "app"
      }
    ]
  }
}

Here is my public/auth.js, where the token is requested and received on the client. This is where I get stuck:

/* global firebase, firebaseui */

const uiConfig = {
  // signInSuccessUrl: '<url-to-redirect-to-on-success>',
  signInOptions: [
    // Leave the lines as is for the providers you want to offer your users.
    firebase.auth.GoogleAuthProvider.PROVIDER_ID,
    // firebase.auth.FacebookAuthProvider.PROVIDER_ID,
    // firebase.auth.TwitterAuthProvider.PROVIDER_ID,
    // firebase.auth.GithubAuthProvider.PROVIDER_ID,
    firebase.auth.EmailAuthProvider.PROVIDER_ID
    // firebase.auth.PhoneAuthProvider.PROVIDER_ID
  ],
  callbacks: {
    signInSuccess () { return false }
  }
  // Terms of service url.
  // tosUrl: '<your-tos-url>'
}
const ui = new firebaseui.auth.AuthUI(firebase.auth())
ui.start('#firebaseui-auth-container', uiConfig)

firebase.auth().onAuthStateChanged(function (user) {
  if (user) {
    firebase.auth().currentUser.getIdToken().then(token => {
      console.log('You are an authorized user.')

      // This is insecure. What should I do instead?
      // document.cookie = '__session=' + token
    })
  } else {
    console.warn('You are an unauthorized user.')
  }
})

What should I do with authenticated ID tokens on the client side?

Cookies/localStorage/webStorage do not seem to be fully securable, at least not in any relatively simple and scalable way that I can find. There may be a simple cookie-based process which is as secure as directly including the token in a request header, but I have not been able to find code I could easily apply to Firebase for doing so.

I know how to include tokens in AJAX requests, like:

var xhr = new XMLHttpRequest()
xhr.open('GET', URL)
xmlhttp.setRequestHeader("Authorization", 'Bearer ' + token)
xhr.onload = function () {
    if (xhr.status === 200) {
        alert('Success: ' + xhr.responseText)
    }
    else {
        alert('Request failed.  Returned status of ' + xhr.status)
    }
}
xhr.send()

However, I don't want to make a single page application, so I cannot use AJAX. I cannot figure out how to insert the token into the header of normal routing requests, like the ones triggered by clicking on an anchor tag with a valid href. Should I intercept these requests and modify them somehow?

What is the best practice for scalable client side security in a Firebase for Web application that is not a single page application? I do not need a complex authentication flow. I am willing to sacrifice flexibility for a security system I can trust and implement simply.

like image 831
David Y. Stephenson Avatar asked Feb 20 '18 11:02

David Y. Stephenson


2 Answers

Why cookies are not secured?

  1. Cookie data can be easily tempered with, if a developer is stupid enough to store logged in user's role in cookie, user can easily alter his cookie data, document.cookie = "role=admin". (voila!)
  2. ‎Cookie data can be easily picked up by a hacker by XSS attack and he can login to your account.
  3. ‎Cookie data can be easily collected from your browser, and your roommate can steal your cookie and login as you from his computer.
  4. ‎Anyone who is monitoring your network traffic can collect your cookie if you are not using SSL.

Do you need to be concerned?

  1. We are not storing anything stupid in the cookie the user can modify to gain any unauthorized access.
  2. ‎If a hacker can pick-up cookie data by XSS attack, he can also pickup the Auth token if we don't use single page application (because we will be storing the token somewhere eg localstorage).
  3. ‎Your roommate can also pickup your localstorage data.
  4. ‎Anyone monitoring your network can also pickup your Authorization header unless you use SSL. Cookie and Authorization are both sent as plain text in http header.

What should we do?

  1. If we are storing the token somewhere, there is no security advantage over cookies, Auth token are best suited for single page applications adding additional security or where cookies are not an available option.
  2. ‎If we are concerned of someone monitoring network traffic, we should host our site with SSL. Cookies and http-headers cannot be intercepted if SSL is used.
  3. ‎If we are using single page application, we should not store the token anywhere, just keep it in a JS variable and create ajax request with Authorization header. If you are using jQuery you can add a beforeSend handler to the global ajaxSetup that sends the Auth token header whenever you make any ajax request.

    var token = false; /* you will set it when authorized */
    $.ajaxSetup({
        beforeSend: function(xhr) {
            /* check if token is set or retrieve it */
            if(token){
                xhr.setRequestHeader('Authorization', 'Bearer ' + token);
            }
        }
    });
    

If we want to use Cookies

If we don't want to implement a single page application and stick to cookies, then there are two options to choose from.

  1. Non-Persistent (or session) cookies: Non-persistent cookies has no max-life/expiration date and gets deleted when the user closes browser window, thus making it so much preferable in situations where security is concerned.
  2. Persistent cookies: Persistent cookies are those with a max-life/expiration date. These cookies persist until the time period is over. Persistent cookies are preferred when you want the cookie to exist even if the user closes the browser and comes back next day, thus preventing authentication every time and improving user's experience.
document.cookie = '__session=' + token  /* Non-Persistent */
document.cookie = '__session=' + token + ';max-age=' + (3600*24*7) /* Persistent 1 week */

Persistent or Non-Persistent which one to use, the choice is completely the project dependent. And in case of Persistent cookies the max-age should be balanced, it should not be a month, or an hour. 1 or 2 weeks look better option to me.

like image 131
Munim Munna Avatar answered Nov 19 '22 10:11

Munim Munna


You're overly skeptical of storing the Firebase ID token in a cookie. By storing it in a cookie, it would be sent with every request to your Firebase Cloud function.

Firebase ID token:

Created by Firebase when a user signs in to a Firebase app. These tokens are signed JWTs that securely identify a user in a Firebase project. These tokens contain basic profile information for a user, including the user's ID string, which is unique to the Firebase project. Because the integrity of ID tokens can be verified, you can send them to a backend server to identify the currently signed-in user.

As its stated in the definition of a Firebase ID token, the integrity of the token can be verified, so it should be safe to store and send to your server. The problem arises in that you don't want to be needing to provide this token in the Authentication header for every request to your Firebase Cloud Function, since you want to avoid using AJAX requests for routing.

This brings it back to utilizing cookies, since cookies are automatically sent with server requests. They're not as dangerous as you're thinking they are. Firebase even has an example application called "Server-side generated pages w/ Handlebars templating and user sessions" that utilizes session cookies for sending the Firebase ID Token.

You can see their example of this here:

// Express middleware that checks if a Firebase ID Tokens is passed in the `Authorization` HTTP
// header or the `__session` cookie and decodes it.
// The Firebase ID token needs to be passed as a Bearer token in the Authorization HTTP header like this:
// `Authorization: Bearer <Firebase ID Token>`.
// When decoded successfully, the ID Token content will be added as `req.user`.
const validateFirebaseIdToken = (req, res, next) => {
    console.log('Check if request is authorized with Firebase ID token');

    return getIdTokenFromRequest(req, res).then(idToken => {
        if (idToken) {
            return addDecodedIdTokenToRequest(idToken, req);
        }
        return next();
    }).then(() => {
        return next();
    });
};

/**
 * Returns a Promise with the Firebase ID Token if found in the Authorization or the __session cookie.
 */
function getIdTokenFromRequest(req, res) {
    if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
        console.log('Found "Authorization" header');
        // Read the ID Token from the Authorization header.
        return Promise.resolve(req.headers.authorization.split('Bearer ')[1]);
    }
    return new Promise((resolve, reject) => {
        cookieParser(req, res, () => {
            if (req.cookies && req.cookies.__session) {
                console.log('Found "__session" cookie');
                // Read the ID Token from cookie.
                resolve(req.cookies.__session);
            } else {
                resolve();
            }
        });
    });
}

This would allow you to not need AJAX and allow routes to be handled by your Firebase Cloud Function. Just be sure to take a look at Firebase's template where they're checking the header on every page.

<script>
    function checkCookie() {
    // Checks if it's likely that there is a signed-in Firebase user and the session cookie expired.
    // In that case we'll hide the body of the page until it will be reloaded after the cookie has been set.
    var hasSessionCookie = document.cookie.indexOf('__session=') !== -1;
    var isProbablySignedInFirebase = typeof Object.keys(localStorage).find(function (key) {
            return key.startsWith('firebase:authUser')
}) !== 'undefined';
    if (!hasSessionCookie && isProbablySignedInFirebase) {
        var style = document.createElement('style');
    style.id = '__bodyHider';
        style.appendChild(document.createTextNode('body{display: none}'));
    document.head.appendChild(style);
}
}
checkCookie();
    document.addEventListener('DOMContentLoaded', function() {
        // Make sure the Firebase ID Token is always passed as a cookie.
        firebase.auth().addAuthTokenListener(function (idToken) {
            var hadSessionCookie = document.cookie.indexOf('__session=') !== -1;
            document.cookie = '__session=' + idToken + ';max-age=' + (idToken ? 3600 : 0);
            // If there is a change in the auth state compared to what's in the session cookie we'll reload after setting the cookie.
            if ((!hadSessionCookie && idToken) || (hadSessionCookie && !idToken)) {
                window.location.reload(true);
            } else {
                // In the rare case where there was a user but it could not be signed in (for instance the account has been deleted).
                // We un-hide the page body.
                var style = document.getElementById('__bodyHider');
                if (style) {
                    document.head.removeChild(style);
                }
            }
        });
    });
</script>
like image 37
mootrichard Avatar answered Nov 19 '22 10:11

mootrichard