Glanceapp & Authentication

Glanceapp & Authentication

Photo by Ferenc Almasi / Unsplash
tl;dr: If you're just here for how I put together a simple, responsive authentication solution for glanceapp, you can skip to here.

Taking a break from thinking about products (well, not really), over the holidays I wanted to get back into self-hosting apps. As you'd expect, I started with: what's the goal we are trying to achieve? Who is the audience and what are the pains they are trying to solve?

If we were to briefly map this based on Strategyzer's Value Proposition Map, we'd be looking at something like this:

  • Customer Side
    • Jobs To Be Done
      • I want a to reduce the amount of tabs that I keep open in my browser because:
        • I want to minimize the amount of RAM that my browser uses.
        • I want to minimize some of the context switching and "doomscolling" behaviors when I'm on certain sites.
      • I want to host a dashboard so that I am in control of the features and security of my data feeds.
    • Pains
      • I get too distracted with many tabs open.
      • Pages automatically refresh due to other memory saving (sleeping tabs) that are helpful in certain contexts, but not for sites I'd rather have static. (Yes, I know that I can configure an exclusion list, but that might not carry over from device to device / browser to browser.)
      • Since I have many sites open across several devices, they're all in different states, causing me to re-scroll or refresh, to see if I can get to the same place as I was on another device.
    • Gains
      • I want to be able to keep an eye on news, events, and articles and refresh only when I really want new content so that I don't lose my place.
      • I want to reduce the amount of visual clutter of my browser, so I can focus more on what's important.
      • I want to be able to quickly access detailed information of what I am interested in.
  • Product Side
    • We are a dashboard that allows users to consolidate different data sources of interest, so that they can, at a glance, have a quick overview of topics on a single page.
    • Pain Relievers
      • We minimize the number of tabs, searching, and constant context switching.
      • Since we are a hosted dashboard, users can easily find their dashboard across different devices for a homogenous experience.
      • We allow cache settings to keep the data presented static and only when the cache expires, do we update the data feed so the user can feel that they are in control of the update rate.
    • Gain Creators
      • We provide a highly configurable dashboard that can include data of personal or professional interest, in a single location.
      • We assist in reducing local resource utilization by allowing users to configure all the data sources of their interest in a single location.
      • We provide a method to help focus and reduce distraction by offering different configuration options to categorize their data sources according to their needs.

When I set out to find something that fits all of this, I stumbled across Glanceapp. If you have some spare time, definitely check the project out - it's a great platform, and if you're at all into self-hosting, this is a pretty good project to learn on.

But there was one problem, one job that I needed it to do, which was to be behind a login screen of some kind. As noted in this issue, there are some workarounds (basic HTTP auth or another provider, for example), but I wanted something that was a better experience than basic HTTP authentication (through Nginx or Apache) but definitely didn't want to stand up another "large" (and yes, this is arguable) service for it. I just wanted something for my own personal use that was "quick" and used basically used the same stack I already was using with Docker and Nginx.

The What & How

What solution did I come up with? The lightest weight was a simple node app to handle the authentication, a json file for the user(s), an html file to handle the login, and a crafty Nginx conf script. While I'm sure that (a) this is totally not scalable and (b) there are probably other solutions for this same problem, Glanceapp is not designed to be multi-user and this was designed to be extremely low maintenance.

What will you need for this? Docker (compose), Nginx, and your favorite text editor or IDE, if that tickles your fancy.

The Authentication Service

Let's start by setting up the authentication service. We'll be running this in its own docker container running node. Create a directory for these files, let's call it:

mkdir ~/auth-service

And we'll start with a Dockerfile like this:

FROM node:18-alpine

WORKDIR /usr/src/app

COPY package*.json ./
RUN npm install

COPY auth-server.js ./
COPY users.json ./

# Change this port number to the port you want to run this service
EXPOSE 3000

CMD ["node", "auth-server.js"]

Dockerfile

This should give you a preview of what we're putting together. You'll also need a package.json file in that same directory for later, so let's get that out of the way.

{
  "name": "auth-service",
  "version": "1.0",
  "description": "A simple authentication service",
  "main": "auth-server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "bcryptjs": "^2.4.3",
    "express": "^4.18.2"
  }
}

package.json

The users.json is pretty straightforward, but you want to make sure that your password is hashed by bcrypt. There are online generators for this (yikes) or you can craft a quick bash script for that. For the users.json, you'll want something like this:

{
  "users": [
    {
      "username": "some_user",
      "password": "$2y$10$P4weemG/.0DD7s1/acLi0uGesyShgN7rWuLsGxBLjHS/I5Rfn1I9u" 
    }
  ]
}

users.json

Please, if you're just copying and pasting this, change that password. It's just the hash of password. Fine for testing, but definitely not to expose it to something that the whole world can access.

Now, we're getting to the fun part. There are many ways to make this better, but for now, it works (this is going to be a pattern, you'll see). In vi or your favorite IDE, you're going to create a file called auth-server.js, which should look like this:

const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const bcrypt = require('bcryptjs');

const app = express();
app.use(express.json());

// Parse cookies from the request headers
function parseCookies(req) {
  const list = {};
  const cookieHeader = req.headers.cookie;
  if (!cookieHeader) return list;
  
  cookieHeader.split(';').forEach(cookie => {
    const [ name, ...rest ] = cookie.split('=');
    const trimmedName = name.trim();
    const value = rest.join('=').trim();
    if (trimmedName && value) {
      list[trimmedName] = decodeURIComponent(value);
    }
  });
  return list;
}

// In-memory session storage
const sessionStore = {};

// Load the users database
const usersPath = path.join(__dirname, 'users.json');
let usersDB = { users: [] };

try {
  const rawData = fs.readFileSync(usersPath, 'utf-8');
  usersDB = JSON.parse(rawData);
} catch (err) {
  console.error('Error reading users.json:', err);
  process.exit(1);
}

// POST /login: { username, password }
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  if (!username || !password) {
    return res.status(400).json({ error: 'Missing credentials.' });
  }

  // Make sure the user exists
  const userRecord = usersDB.users.find(u => u.username === username);
  if (!userRecord) {
    return res.status(401).json({ error: 'Invalid username or password.' });
  }

  // Compare the plaintext password with the stored hashed password
  const match = await bcrypt.compare(password, userRecord.password);
  if (!match) {
    return res.status(401).json({ error: 'Invalid username or password.' });
  }

  // Success -> create a session
  const sessionId = crypto.randomBytes(16).toString('hex');
  sessionStore[sessionId] = {
    username: username,
    createdAt: new Date()
  };

  // Set session cookie
  res.cookie('session_id', sessionId, {
    httpOnly: true,
    secure: false,     // change to true in production if behind HTTPS
    sameSite: 'Strict' // optional, helps mitigate CSRF
  });

  return res.json({ message: 'Logged in successfully.' });
});

// GET /validate (for auth_request in Nginx)
app.get('/validate', (req, res) => {
  // Grab the session_id cookie
  const cookies = parseCookies(req);
  const sessionId = cookies['session_id'];

  // Check if it's in our in-memory store
  if (sessionId && sessionStore[sessionId]) {
    return res.sendStatus(200); // user is authenticated
  } else {
    return res.sendStatus(401); // no session or invalid session
  }
});

// Listen on configured port in the Dockerfile
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`auth-service is running on port ${PORT}`);
});

auth-server.js

We're just storing the sessions in memory, so if you restart the container, session is lost. Restart the server. Session is lost. We're also not expiring the sessions, but mileage may vary depending on the client device.

We're done with the (really) simple authentication service. Time to build and get the container up and running with:

docker compose build
docker compose up -d

You can always verify that this is running and responding by (and you should get a GET error, because, well, there are no GET endpoints):

curl http://localhost:<port that you configured>

The Login Page

Let's get the login page done. I wanted something that was responsive and matched the theme of my dashboard. The example below will match Glanceapp's default theme. If you're already creating your own theme, the inline CSS should be clear enough to go ahead and edit it to your liking.

Navigate to where you want to serve this file from (maybe, /var/www/) and create a directory for it (can I suggest, mydashboard). Great! Let's go back and with vi (or with your fancy IDE), create index.html, and you want it look like this:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Glanceapp Login</title>
  <style>
  body {
    font-family: 'JetBrains Mono', monospace;
    margin: 0;
    padding: 2rem;
    background-color: hsl(240, 8%, 9%);
    color: hsl(43, 50%, 70%);
  }
  .login-container {
    max-width: 300px;
    margin: 0 auto;
    background-color: hsl(240, 8%, 15%);
    padding: 2rem;
    border-radius: 8px;
  }
  input {
    display: block;
    width: 100%;
    margin-bottom: 1rem;
    padding: 0.6rem;
    box-sizing: border-box;
    background-color: hsl(240, 8%, 20%);
    border: 1px solid hsl(43, 50%, 70%);
    color: hsl(43, 50%, 70%);
  }
  button {
    padding: 0.6rem;
    width: 100%;
    background-color: hsl(120, 70%, 70%);
    border: none;
    color: hsl(240, 8%, 9%);
    cursor: pointer;
    transition: background-color 0.3s ease;
  }
  button:hover {
    background-color: hsl(120, 70%, 60%);
  }
  .error {
    color: hsl(0, 100%, 70%);
    margin-bottom: 1rem;
  }
  @media (max-width: 600px) {
    body {
      padding: 1rem;
    }
    .login-container {
      max-width: 100%;
    }
  }
  .error {
    color: red;
    margin-bottom: 1rem;
  }
  </style>
</head>
<body>
<div class="login-container">
  <h1>LOGIN</h1>
  <div id="error" class="error"></div>
  <form id="loginForm">
    <label for="username">USERNAME</label>
    <input type="text" id="username" required />

    <label for="password">PASSWORD</label>
    <input type="password" id="password" required />

    <button type="submit">LOGIN</button>
  </form>
</div>

<script>
  const form = document.getElementById('loginForm');
  const errorDiv = document.getElementById('error');

  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    errorDiv.textContent = '';

    const username = document.getElementById('username').value.trim();
    const password = document.getElementById('password').value;

    try {
      // Send credentials to our auth-server
      const resp = await fetch('/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password })
      });
      const data = await resp.json();

      if (resp.ok) {
        // Success -> redirect to the Glance app by reload (href didn't work)
        window.location.reload(true);
      } else {
        // Show login error message
        errorDiv.textContent = data.error || 'Invalid credentials.';
      }
      // Catch any other error, generically
    } catch (err) {
      errorDiv.textContent = 'Network error. Please try again later.';
    }
  });
</script>
</body>
</html>

index.html

All we're doing is presenting the user with a really simple login form and passing that to our authentication server's POST endpoint. If there's a typo, it should return "Invalid credentials.", but if there's something else going on (the auth server's container is down or some network error), just plaster a totally, not generic "Network error. Please try again later." message. Sure, we could be more verbose or offer more information to the user, but for a wave 1 release of this, it works.

Nginx Black Magic

Honestly, I have a love/hate relationship with Nginx. It's dead simple to remember the typical things you need to reverse proxy an app, but when you start getting into these cross reverse proxy things between two containers, it's always confusing. As a friend of mine puts it: it's all Nginx black magic.

With that off my chest, I'm assuming a few things here, but the most basic is that you have an SSL certificate for the domain you're going to use. If you don't, go set up a barebones where-you-are-hosting-this-thing.conf and use Let's Encrypt. Then come back.

Back? Cool. Alright, so that means you already have where-you-are-hosting-this-thing.conf in sites-enabled, so go back there and open it in vi (or use an IDE, for extra overkill) and in the SSL protected server block you want these location blocks:

server {	

 ... SSL BLOCK by certbot ...
 
    location /index.html {
	    root /var/www/mydashboard;
	    try_files $uri $uri/ =404;
    }

    location /auth/ { proxy_pass http://127.0.0.1:auth-server-port/; }

    location = /internal-auth-verify {
	    internal;
	    proxy_pass http://127.0.0.1:auth-server-port/validate;
	    proxy_pass_request_body off;
	    proxy_set_header Content-Length "";
	    proxy_set_header X-Original-URI $request_uri;
    }

    location / {
	    auth_request /internal-auth-verify;
	    error_page 401 = /index.html;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
	    proxy_set_header X-Real-IP $remote_addr;
	    proxy_set_header Host $http_host;
	    proxy_pass http://127.0.0.1:glanceapp-port/;
    }

}

the location blocks for your where-you-are-hosting-this-thing.conf

What this is doing is that you're telling the user:

  • Hey, you've arrived at "/", but you aren't authenticated, so I'm gonna give you a 401 Unauthenticated error.
  • I'll also do you a favor because the 401 error page is really unhelpful. I'll just redirect you to index.html (that same one you created above).
  • The user authenticates.
  • Yay! They're in!

So go ahead, if you're feeling brave and you're sure everything is OK, and run:

sudo systemctl reload nginx

Or run nginx -t before you do that to check for errors, so you don't bring down anything else that also hosted on the same machine.

But what about the unhappy path? They tried to access https://where-you-are-hosting-this-thing.tld/somepage that doesn't exist? Well, they'll also get the index.html served to them because we're telling Nginx that any page we serve, it needs to go validate the session with that "special" auth_request /internal-auth-verify.

Drawbacks? Well, remember in that index.html we have a line that says window.location.reload(true)? Yeah. Works fine for our purposes, but if you actually go to: https://where-you-are-hosting-this-thing.tld/somepage and authenticate, you'll probably get a 404 error. Not a great user experience and definitely an unintended consequence, but for now, it works.

This was a fun exercise and if this was what you were looking for, awesome! Someone else got some use out of my "bang head against keyboard for 12 hours". If this wasn't what you were looking for, thanks for reading 2500 rambling words, I guess?

And just in case, this entire exercise is free to use. Attribution is nice, but totally not necessary. It's not designed for production and is purely for educational purposes. I am not liable for any data loss, weird network interactions, if your dashboard gets owned by someone else, or if Nginx decides to eat your website.

/r