Javascript Auth With Hono and Lucia

Written by Basile Samel.

Published Oct 10, 2024. Last updated Oct 10, 2024.

Let’s solve auth once and for all.

1. Database Service

We use sqlite to make testing and replication simpler.

src/services/db.js

import Database from "better-sqlite3";
import fs from "node:fs/promises"

let db_singleton = false

const path = `${import.meta.dirname}/../../db/database.db`;

export function db () {
    if (!db_singleton) {
        db_singleton = new Database(path);
    }
    
    return db_singleton;
}

export function destroyDatabase(){
    return fs.rm(path)
}

export default db

Following Lucia Auth’s documentation, we settle for this schema:

schema.sql

CREATE TABLE user (
    id TEXT NOT NULL PRIMARY KEY,
    github_id VARCHAR(255) UNIQUE,
    username VARCHAR(255),
    email VARCHAR(255),
    avatar_url VARCHAR(255),
    hasPaid BOOLEAN DEFAULT FALSE,
    role VARCHAR(255) DEFAULT "USER"
);

CREATE TABLE session (
    id TEXT NOT NULL PRIMARY KEY,
    expires_at INTEGER NOT NULL,
    user_id TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES user(id)
);

I use some scripts to make development easier:

package.json

"scripts": {
    "db:create": "sqlite3 ./db/database.db < ./schema.sql",
    "db:destroy": "rm ./db/database.db",
    "db:recreate": "npm run db:destroy && npm run db:create"
  },

Finally, I create a simple testing template I can run with node --test:

test/all.js

import test, { before, after } from 'node:test'
import assert from 'node:assert'

import { destroyDatabase } from "../src/services/db.js"

before(async () => {
    
});

after(async () => {
    await destroyDatabase()
})

2. Lucia Auth Service

lucia-auth is a Javascript library with nice authentication and authorization helper functions.

I create a dedicated service to use it more easily across my codebase:

src/services/auth.js

import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite";
import { Lucia } from "lucia"

import dotenv from "dotenv";
dotenv.config();

import db from "./db.js";

const adapter = new BetterSqlite3Adapter(db(), {
	user: "user",
	session: "session"
});

const auth = new Lucia(adapter, {
	sessionCookie: {
		attributes: {
			// secure: true,
			domain: process.env.FRONT_DOMAIN
		}
	},
	getSessionAttributes: (attributes) => {
		return {
			username: attributes.username
		};
	},
	getUserAttributes: (attributes) => {
		return {
			githubId: attributes.github_id,
			username: attributes.username,
			avatar_url: attributes.avatar_url,
			hasPaid: attributes.hasPaid,
		};
	},
});

export default auth

I opt for Github OAuth as an identity provider. Perfect for building a devtool fast.

3. User Service

I need a User service to handle CRUD transactions with the sqlite database:

src/services/user.js

import { v4 as uuid } from 'uuid';
import { generateId } from "lucia";

import db from "./db.js"

export function createTable () {
    return db().prepare(`
        CREATE TABLE IF NOT EXISTS user (
            id TEXT NOT NULL PRIMARY KEY,
            uid VARCHAR(36) UNIQUE,
            github_id VARCHAR(255) UNIQUE,
            username VARCHAR(255),
            email VARCHAR(255),
            avatar_url VARCHAR(255),
            hasPaid BOOLEAN DEFAULT FALSE,
            role VARCHAR(255) DEFAULT "USER"
        );
    `)
    .run()
}

export async function create (args) {
    const id = generateId(15)

    const res = await db().prepare(`
        INSERT INTO user 
        (id, uid, github_id, username, email, avatar_url) VALUES 
        (?, ?, ?, ?, ?, ?);
    `)
    .run(
        id,
        uuid(), 
        args.github_id, 
        args.username,
        args.email, 
        args.avatar_url
    )

    return findOneById(id)
}

export function findOneById (id) {
    return db().prepare(`
        SELECT * FROM user WHERE id = ? LIMIT 1;
    `)
    .get(id)
}

export function findOne (uid) {
    return db().prepare(`
        SELECT * FROM user WHERE uid = ? LIMIT 1;
    `)
    .get(uid)
}

export function findOneByGithubId (id) {
    return db()
    .prepare("SELECT * FROM user WHERE github_id = ? LIMIT 1")
    .get(id);
}

export function update (user) {
    return db().prepare(`
        UPDATE user
        SET 
        username = ?,
        email = ?,
        avatar_url = ?
        WHERE uid = ?;
    `)
    .run(
        user.username, 
        user.email,
        user.avatar_url,
        user.uid
    )
}

export function save (user) {
    const id = user.id

    if(id) {
        return update(user)
    }

    return create(user)
}

export function remove (user) {
    return db().prepare(`
        DELETE FROM user WHERE uid = ?;
    `)
    .run(user.uid)
}

To make sure everything works, I add the following unit tests:

test/all.js

import test, { before, after } from 'node:test'
import assert from 'node:assert'

import * as UserService from "../src/services/user.js"

before(async () => {
    await UserService.createTable()
});

let uid = false

test('create a new user', async () => {
    const user = await UserService.create({
        github_id: "ok",
        username: "ok",
        email: "ok",
        avatar_url: "ok"
    })

    assert(user.email == "ok")
    
    uid = user.uid
})

test('find a user by uid', async () => {
    const user = await UserService.findOne(uid)

    assert(user.email == "ok")
})

test('update a user', async () => {
    const user = await UserService.findOne(uid)
    user.email = "updated"

    await UserService.update(user) 

    const updated_user = await UserService.findOne(uid)
    assert(updated_user.email = "updated")
})

test('delete a user', async () => {
    await UserService.remove({uid})

    const user = await UserService.findOne(uid) 

    assert(!user)
})

4. Session Service

I do the same with the Session entity:

src/services/session.js

import db from "./db.js"
import auth from "./auth.js";

export function createTable () {
    return db().prepare(`
        CREATE TABLE IF NOT EXISTS session (
            id TEXT NOT NULL PRIMARY KEY,
            expires_at INTEGER NOT NULL,
            user_id TEXT NOT NULL,
            FOREIGN KEY (user_id) REFERENCES user(id)
        );
    `)
    .run()
}

export function create (user) {
    return auth.createSession(user.id, {})
}

export function findOne (id) {
    return db().prepare(`
        SELECT * FROM session WHERE id = ? LIMIT 1;
    `)
    .get(id)
}

export function remove (session) {
    return auth.invalidateSession(session.id)
}

And the unit tests:

test/all.js

import * as UserService from "../src/services/user.js"
import * as SessionService from "../src/services/session.js"

before(async () => {
    await UserService.createTable()
    await SessionService.createTable()
});

let id = false

// ...

test('create a new session', async () => {
    const user = await UserService.create({
        github_id: "session",
        username: "session",
        email: "session",
        avatar_url: "session"
    })

    const session = await SessionService.create(user)

    assert(session.id)
    
    id = session.id
})

test('find a session by id', async () => {
    const session = await SessionService.findOne(id)

    assert(session.id == id)
})

test('delete a session', async () => {
    await SessionService.remove({id})

    const session = await SessionService.findOne(id) 

    assert(!session)
})

5. Basic Hono Server

I prefer Hono over Express. Easier to set up. Standard Web APIs. Cross-compatibility with several serverless platforms like Cloudflare or AWS.

src/index.js

import { Hono } from 'hono'

import {
    getCookie,
    setCookie,
} from 'hono/cookie'

import dotenv from "dotenv";
dotenv.config()

import { verifyRequestOrigin, generateId } from "lucia";

import * as SessionService from "./services/session.js"
import * as UserService from "./services/user.js"
import auth from "./services/auth.js"

const app = new Hono()

export default app

For a NodeJS development server:

index.js

import { serve } from '@hono/node-server'
import app from "./src/index.js"

serve(app)

Easy to run:

package.json

"scripts": {
    "dev": "node --watch ./index.js",
    // ...
  },

6. Github OAuth Controller

Now for the meaty part, I just followed Lucia Auth’s official docs to create the two endpoints necessary for OAuth.

One to redirect the user to Github. The other to create a session if the authentication is successful.

import { generateState, GitHub } from "arctic";

const github = new GitHub(
	process.env.GITHUB_CLIENT_ID,
	process.env.GITHUB_CLIENT_SECRET
)

app.get('/login/github', async ctx => {
    const state = generateState();
	const url = await github.createAuthorizationURL(state);
	
	
	setCookie(ctx, "github_oauth_state", state, {
		path: "/",
		secure: true,
		httpOnly: true,
		maxAge: 60 * 10,
		sameSite: "Lax"
	});

	let res = url.toString()

	return ctx.redirect(res);
})

app.get('/login/github/callback', async ctx => {
	const code = ctx.req.query("code")?.toString() ?? null;
	const state = ctx.req.query("state")?.toString() ?? null;
	
	const storedState = getCookie(ctx).github_oauth_state ?? null;
	
	if (!code || !state || !storedState || state !== storedState) {
		throw new Error("Invalid state");
	}

	try {
		const tokens = await github.validateAuthorizationCode(code);
		
		const githubUserResponse = await fetch("https://api.github.com/user", {
			headers: {
				"X-GitHub-Api-Version":"2022-11-28",
				"Accept": "application/vnd.github+json",
				"Authorization": `Bearer ${tokens.accessToken}`,
				"User-Agent": "clowdr"
			}
		})
		.then(res => res.json())
	
		let existingUser = await UserService.findOneByGithubId(githubUserResponse.id);
			
		if (!existingUser) {
			existingUser = await UserService.create({
				github_id: githubUser.id,
				username: githubUser.login,
				email: githubUser.email,
				avatar_url: githubUser.avatar_url
			});
		}
		
		const session = await SessionService.create(existingUser);

		setCookie(ctx, "session", session.id, {
			path: "/",
			secure: true,
			httpOnly: true,
			maxAge: 60 * 10,
			sameSite: "Strict"
		});
		
		return ctx.redirect(`http://localhost:4321/studio?login=0&username=${githubUser.login}&avatar_url=${encodeURIComponent(githubUser.avatar_url)}`);
	} catch (e) {
		throw new Error(e.message)
	}
})

I’m using a httpOnly cookie to store the session id for API authorization. Unlike JWT auth, it’s impossible to meddle with on the front-end with XSS attacks, but I need to send some info in the redirect URL to display profile information.

Adding a little test to make sure the cookie is formatted correctly for Github:

test/all.js

import cookie from "cookie"

import app from "../src/index.js"

test('generate Github Oauth url', async (t) => {
    const res = await app.request('/login/github')

    const cookies = cookie.parse(res.headers.get('set-cookie'));

    assert(cookies.github_oauth_state)
})

I’m not testing the callback endpoint. Already tested the User and Session services, and kind of annoying to create fake Github OAuth data to use as input.

7. Authorization

We can then use our session cookie to authenticate API calls and authorize the corresponding user in a middleware:

app.use('/api/*', async (ctx, next) => {
	const originHeader = ctx.req.header("Origin");
	
	const hostHeader = "clowdr.com";

	if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) {
		return new Response(null, {
			status: 403
		});
	}
	
	const sessionId = getCookie(ctx, "session")

	const { session } = await auth.validateSession(sessionId);

	if (!session) {
		const err = new Error("Forbidden");
		err.status = 403;
		throw err;
	}

	ctx.data = {
		session
	};

    await next()
})

Note that we check the origin header to prevent CSRF attacks, in combination with CORS policies to restrict access.

8. Logout Controller

On the backend, logging out corresponds to deleting active sessions and the corresponding cookie:

app.post('/logout', async ctx => {
	const session = getCookie(ctx, "session");
	
	if (!session) {
		return ctx.body(null, 401);
	}
	
	await SessionService.remove(session);
	
	ctx.header("Set-Cookie", auth.createBlankSessionCookie().serialize(), { append: true });

	return ctx.redirect("http://localhost:4321/login?logout=true");
})

9. User Controller

We can test the authorization middleware with a simple API endpoint that queries stored user information:

app.get('/api/user', async ctx => {
    const session = ctx.data.session 

    const user = await UserService.findOneById(session.userId)

    return ctx.json({
        ok: true, 
        user 
    })
})

And a last little test to make sure authorization works:

test/all.js

test('parse cookie from request', async (t) => {
    const user = await UserService.create({
        github_id: "ok",
        username: "ok",
        email: "ok",
        avatar_url: "ok"
    })

    const session = await SessionService.create(user)

    const res = await app.request('/api/user', {
        headers: {
            'Origin': "https://clowdr.com",
            'Cookie': `session=${session.id};httpOnly`
        }
    })
    .then(res => res.json())
    
    assert(res.ok)
    assert(res.user.id == user.id)
    assert(res.user.role == "USER")
})

And voilà.

Susbcribe to the newsletter