Description
Database adapters have been a fundamental part of Lucia from the very start. It allows Lucia to work with any databases and Lucia provides adapter packages to make the integration easier. But is this correct approach?
I've... actually never considered that. It just made sense when starting, especially since that's how libraries like NextAuth did it. But now I have a definite answer. The adapter model is fine but we shouldn't provide any built-in adapters.
The ready-made adapters are a pain to work with and has the domino effect of making Lucia core bloated. From the start, it's been a struggle make each adapter flexible while keeping the API simple. Take for example the many SQL adapters. We have to dynamically create prepared statements based on custom attributes and escape the table and column names. And it affects the core as well. I don't think anybody likes getUserAttributes()
and define module
. These API had to be included to provide flexibility and type-inference. But is it worth it? We added all these layers of abstractions so that so devs don't have to manually write queries, but really that's probably the easiest thing for web devs to do.
I'm now convinced that it's a bad idea to create libraries around database drivers and ORMs if you want to provide any sort of flexibility.
So if Lucia doesn't write your queries for you, what can the next version of Lucia look like?
// auth.ts
import { Lucia } from "lucia";
import { db } from "./db.js";
import type { DatabaseAdapter, SessionAndUser } from "lucia";
const adapter: DatabaseAdapter<Session, User> = {
getSessionAndUser: (sessionId: string): SessionAndUser<Session, User> => {
const row = db.queryRow(
"SELECT session.id, session.user_id, session.expires_at, session.login_at, user.id, user.username FROM session INNER JOIN user ON user.id = session.user_id WHERE session.id = ?",
[sessionId]
);
if (row === null) {
return { session: null, user: null };
}
const session: Session = {
id: row[0],
expiresAt: new Date(row[2] * 1000),
loginAt: new Date(row[3] * 1000),
};
const user: User = {
id: row[4],
username: row[5],
};
return { session, user };
},
deleteSession: (sessionId: string): void => {
db.execute("DELETE FROM session WHERE id = ?", [sessionId]);
},
updateSessionExpiration: (sessionId: string, expiresAt: Date): void => {
db.execute("UPDATE session SET expires_at = ? WHERE id = ?", [
expiresAt,
sessionId,
]);
},
};
export const lucia = new Lucia(adapter, {
sessionCookie: {
secure: false,
},
});
export interface User {
id: string;
username: string;
}
export interface Session extends LuciaSession {
userId: string;
loginAt: Date;
}
const { session, user } = await lucia.validateSession(sessionId);
if (session === null) {
throw new Error("Invalid session");
}
const username = user.username;
The biggest and obvious change is that you have to manually define your adapter. I've used a SQL driver as an example so it's a bit verbose (also see the very clean Prisma adapter), but the queries aren't that complicated at all. We could add sample code for popular drivers and ORMs in the docs, which would make the setup easier too.
From an API design perspective, I think this is beautiful. For starters, minimal generic usage and config options. But I really love the most is that LuciaSession
is just an interface that your custom session object needs to satisfy. No more getSessionAttributes()
or declare module
funkiness. This also means that discriminated unions just works for both sessions and users
const adapter: DatabaseAdapter<Session, User> = {
/* ... */
};
type Session = SessionA | SessionB;
export interface SessionA extends LuciaSession {
a: any;
}
export interface SessionB extends LuciaSession {
b: any;
}
For creating session, just write a single query. We'll provide the utilities for generating IDs and calculating expirations.
import { lucia, generateSessionIdWithWebCrypto } from "lucia";
import { db } from "./db.js";
export async function createSession(userId: string): string {
const sessionId = generateSessionIdWithWebCrypto();
const expiresAt = lucia.getNewSessionExpiration();
db.execute(
"INSERT INTO session (id, expires_at, user_id, login_at) VALUES (?, ?, ?)",
[sessionId, expiresAt, userId, Math.floor(new Date().getTime() / 1000)]
);
return sessionId;
}
At this point, why keep the user handling? I've thought about this for quite a while, and the simple answer is that it's better to have an API that works with one thing (user + session) rather than two things (user + session or session). If you just need any more flexible than this - just build your own. It's likely that whatever API we provide won't be enough. I think it's fine for Lucia to have some opinions and this feels like the place to draw the line.
On a final note, this could allow us to bring back framework-specific APIs. I don't have the appetite to deal with the mess of JS frameworks, but maybe :D
This will be part of Lucia v4, which will probably be released late this year or early next year. It's very possible that v4 will be our last major update.