SSO with Firebase

I got it working!

Hey everyone! Extremely happy to report I’ve gotten Firebase working as an SSO option for Discourse. It was as I had suggested in my last post that the key to making this work was to leverage the new ability for the Firebase SDK to set cookies on the browser after a login session.

On client-side you’ll need to do something like this:

auth.setPersistence(firebase.auth.Auth.Persistence.SESSION);

Which first sets the persistence state of your Firebase session.

Then you’ll need to tie into your users login flow with Firebase’s token logic. The implementation on our site is like this:

auth.signInWithEmailAndPassword(email, password).then(function(credential) {
    credential.user.getIdToken(true).then(function(token) {
    var FD = new FormData();
    FD.append("idToken", token);
    $.ajax({
        url: 'ENDPOINTURL',
        xhrFields: { withCredentials: true },
        data: FD,
        cache: false,
        contentType: false,
        processData: false,
        type: 'POST'
    })
    .done(function(data) {
         // redirect to wherever you want a logged in user to go to
          location.href = "/account";
    })
    .fail(function(xhr) {
         log('error', xhr);
    });
});

This hits an endpoint on our (node/express) server, which looks like this:

app.post('ENDPOINTURL',formidable(),function(req,res) {
  // Get the ID token passed
  var idToken = req.fields.idToken;
  // Set session expiration to 5 days.
  var expiresIn = 60 * 60 * 24 * 5 * 1000;
  // Create the session cookie. This will also verify the ID token in the process.
  // The session cookie will have the same claims as the ID token.
  // We could also choose to enforce that the ID token auth_time is recent.
  auth.verifyIdToken(idToken).then(function(decodedClaims) {
      // In this case, we are enforcing that the user signed in in the last 5 minutes.
      if (new Date().getTime() / 1000 - decodedClaims.auth_time < 5 * 60) {
      return auth.createSessionCookie(idToken, {expiresIn: expiresIn});
    }
       throw new Error('UNAUTHORIZED REQUEST!');
  })
  .then(function(sessionCookie) {
        // Note httpOnly cookie will not be accessible from javascript.
        // secure flag should be set to true in production.
        var options = {maxAge: expiresIn, domain: 'depthkit.tv', httpOnly: false, secure: true /** false to test in localhost */};
        res.cookie('session', sessionCookie, options);
        res.end(JSON.stringify({status: 'success'}));
  })
  .catch(function(error) {
        res.status(401).send('UNAUTHORIZED REQUEST!');
  });
});

Note that this is mostly a reference implementation from Google’s sample here. Also, auth is our firebase admin account Auth object.

This sets the session cookie in the client browser. It’s worth noting here as well that we are using subdomains, so the intended and implemented ability is that you can login/register at depthkit.tv and then login to our forums at a subdomain using SSO.

Lastly, Discourse has to ping our server for SSO auth (triggered on clicking Login), which looks like this.

app.get('SSOENDPOINT', cookieParser(), function(req,res) {
    const sessionCookie = req.cookies.session || '';
    // Verify the session cookie. In this case an additional check is added to detect
    // if the user's Firebase session was revoked, user deleted/disabled, etc.
    auth.verifySessionCookie(sessionCookie, true /** checkRevoked */)
        .then((decodedClaims) => {
        //once we are here the user cookie is known to be valid and we can extract the uid
        var uid = decodedClaims.uid;
        var payload = req.query.sso; // fetch from incoming request
        var sig = req.query.sig; // fetch from incoming request
        if(sso.validate(payload, sig)) 
        {
            //valid sso, make sure the user is valid
            auth.getUser(uid)
            .then(function(userRecord) {
                // Successfully fetched user data
                var nonce = sso.getNonce(payload);
                var userparams = {
                // Required, will throw exception otherwise
                "nonce": nonce,
                "external_id": uid,
                "email": userRecord.email,
                // Optional - could pull these from DB values
                "username": userRecord.displayName,
                "name": userRecord.displayName
                };
                var q = sso.buildLoginString(userparams);
                console.log("user authed and signed in through SSO, redirecting");
                res.redirect('http://FORUMURL/session/sso_login?' + q);
            })
            .catch(function(error) {
                console.log("Error fetching user data:", error);
                console.log("redirecting to login because of invalid user data");
                res.redirect('https://www.depthkit.tv/login');
            });
        }
        else
        {
            console.log("unable to validate discourse payload, redirecting to login");
            res.redirect('https://www.depthkit.tv/login');
        }
        }).catch(error => {
        // Session cookie is unavailable or invalid. Force user to login.
        console.log("invalied session cookie, redirecting to login");
        res.redirect('https://www.depthkit.tv/login');
    });
});

This is also largely pulled from Firebase’s sample implementation as well as the reference code for the discourse-sso node (sso in the code) package (thank you to the creator!!).

Learned a lot about working with the web in general when implementing this, so hopefully other people can find it useful as well!

12 Likes