Session sharing – Sharing login state between web apps

Session sharing was my first task working on https://philanthropyu.org. It was also the first task assigned to me on a real team at Arbisoft.

The 2 systems that I had to integrate were:

  • Open edX – A learning management system primarily built using Python and Django
  • NodeBB – A forum software built using Node.js and socket.io

Now, at that time I implemented the solution however best I knew. It wasn’t perfect, the code was bad and had quite a few bugs to work out. But, it worked and taught me how to share session info between systems. The technique is more formally known as Single sign-on (SSO).

In this post I’ll build a sample implementation of session sharing between a Python app (App 2) and a Node.js app (App 1).

As all SSO systems are designed, there has to be a central application which will be responsible for the authentication of the user. In my case I can choose either the Node.js or Python app, so I’m just going to go with Node.

TL; DR

Complete code for this tutorial is available at: https://github.com/asamolion/tutorial-session-sharing

The technique I use is summarized as follows:

  • App 1 and App 2 are two separate apps that are hosted on different subdomains on the same second level domain.
  • User signs in to App 1 using username and password.
  • App 1 signs a JSON web token with a secret key and stores it in the browser cookies. This token contains the username, email and any other information required by App 2. The secret key is securely stored separately in both applications.
  • User redirects to App 2.
  • App 2 decodes the token stored in the cookies using the secret key and extracts username and other info from it.
  • App 2 checks its database for the user, if the user doesn’t exist it is created because we know that the source is a secure token from App 1. If the user exists, then it is simply logged in.

Logging in

As stated above, App 1 is responsible for the authentication of the user. Meaning that even if the user wants to log into App 2, he/she will have to login through App 1.

Let’s go through the code of App 1 shall we. Here is the login function of App 1.

app.post('/login', function(req, res) {
	const { username, password } = req.body;

	db.get(
		'SELECT * FROM users where username = $username;',
		{
			$username: username
		},
		function(err, row) {
			if (err) {
				console.error(err);
				res.render('index', {
					error: err
				});
				return;
			}

			if (row === undefined) {
				res.render('index', {
					error: "Sorry, but that user doesn't exist"
				});
				return;
			}

			// If the user exists and password is correct
			// the store the token in the browser cookies under
			// .domain
			if (bcrypt.compare(password, row.password)) {
				res.cookie(
					'token',
					jwt.sign(
						{ username: row.username, email: row.email },
						config.secret
					),
					{
                         			// NOTICE the . behind the domain. This is necessary to ensure
			                        // that the cookies are shared between subdomains

						domain: `.${config.domain}`
					}
				);

				res.redirect('/');
				return;
			}
		}
	);
});

This is the main login function of App 1. This takes the username and password supplied by the user and queries the database for a user with the same username. Since, there is a UNIQUE constraint on the username column in the DB, we can be sure that it will return only a single row. After that, if the user has supplied the correct password, a browser cookie named token is saved to the browser which has the value of a JSON web token. The token is generated by signing an object containing the user’s username and email with a secret key that is in the app config. Also, the cookie is restricted to .domain. The . in the beginning is important because it ensures that the cookie is shared between all subdomains of this domain.

Sharing the login – App 2

The cookie is stored and available for consumption in App 2 since the user has already logged into App 1. When the user redirects to app 2. The following logic is called.

@app.route("/authorized")
def hello():
    # get token from the cookies
    token = request.cookies.get('token')

    # if token doesn't exist then redirect
    if not token:
        return redirect('/')

    if token:
        # decode token using secret key from config
        token_data = jwt.decode(request.cookies.get(
            'token'), config.get('secret'))

        # check if user exists with matching data from token
        user_exists = get_db().execute('SELECT * from users where username = ? and email = ?',
                                      (token_data.get('username'), token_data.get('email'),)).fetchone()

        if user_exists:
            res = make_response(render_template('authorized.html'))
        else:
            # ideally the user creation logic would be added here
            # but I have omitted it for brevity
            res = make_response(render_template('index.html'))

    return res

Here we can see that if the token is not present, the app redirects to the home page. If it is present, the app uses the secret key in config to decode it. The secret key is the same that was used in App 1. The decoded data is then used to query the database and find a user. If the user exists in the DB then he/she is logged in else the user is created. I’ve omitted the user creation logic here because the password isn’t supplied to App 2.

Below is a screencast of how these apps work.

How it works from a user perspective

Wrapping it up

So this is a summary of the way I implemented session sharing on https://philanthropyu.org. The code and examples here are not production ready but what I wrote for PhilU has been successfully working for more than a year in their production environment with over 100,000 users.

The github repo contains a complete sample implementation of this technique with documentation.