7 min read
Migrate Lucia 3

Why Migrate

The author of Lucia plans to stop maintaining Lucia 3 on January 1, 2025.

He believes that Lucia should become an open-source resource for JavaScript authentication.

Because its underlying implementation is very simple, based on two libraries: @oslojs/encoding and @oslojs/crypto.

So the author hopes that we can learn how to implement permission control from scratch, rather than installing this library.

You can refer to:

https://github.com/lucia-auth/lucia/discussions/1707

Why I Still Use Lucia?

I tried using next-auth before, but the documentation was still a big problem. I needed to look at its source code to meet my needs, and the author’s views on password were also a concern. Ultimately, I decided to use Lucia.

Here are the reasons why I use Lucia:

  • Lucia is still a great original library for implementing authentication from scratch.
  • Independent of paid third-party services
  • Can learn authentication and take control

Install Dependencies

pnpm add @oslojs/encoding @oslojs/crypto

If you have previously installed Lucia and related adapters, please delete them first. I deleted:

pnpm remove lucia @lucia-auth/adapter-drizzle

lib/lucia/crypto.ts

First, create the lib/lucia/crypto.ts file, which mainly contains the logic for generating session tokens and hashing tokens.

import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';

export function generateIdFromEntropySize(size: number): string {
  const buffer = crypto.getRandomValues(new Uint8Array(size));
  return encodeBase32LowerCaseNoPadding(buffer);
}

export function hashToken(token: string) {
  return encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
}

lib/lucia/index.ts

Create the lib/lucia/index.ts file, which mainly contains the logic for creating sessions and validating session tokens.

I use drizzle-orm here, so I need to import the types of drizzle-orm for session and user.

If you use prisma or other databases, please replace them yourself.

Basic Types

This part mainly defines the result type of session verification.

It defines the expiration time of the session as 30 days, which you can adjust according to your actual situation.

And it defines that if the session reaches 15 days, it needs to be refreshed.

import { Session } from '@/zod/session';
import { SelectUser } from '@/zod/user';

export type SessionValidationResult =
  | { session: Session; user: SelectUser; fresh: boolean }
  | { session: null; user: null; fresh: boolean };

const SESSION_REFRESH_INTERVAL_MS = 1000 * 60 * 60 * 24 * 15; // 15 days
const SESSION_MAX_DURATION_MS = SESSION_REFRESH_INTERVAL_MS * 2; // 30 days

Generate Session Token

import { generateIdFromEntropySize, hashToken } from './crypto';

export function generateSessionToken(): string {
  const token = generateIdFromEntropySize(20);
  return hashToken(token);
}

Create Session

My db is drizzle-orm, so the logic for creating sessions is as follows:

import { db } from '@/db';
import { sessionTable } from '@/db/schema';

export async function createSession(sessionToken: string, userId: string): Promise<Session> {
  const session = {
    id: sessionToken,
    userId,
    expiresAt: new Date(Date.now() + SESSION_MAX_DURATION_MS),
  };
  const [dbSession] = await db.insert(sessionTable).values(session).returning();
  return dbSession;
}

Validate Session Token

This part mainly verifies whether the session token is valid. If it is valid, it returns the session and user.

If the session expires, it deletes the session.

If the session is about to expire and reaches the previously set refresh time, it refreshes the session.

import { db } from '@/db';
import { sessionTable } from '@/db/schema';
import { eq } from 'drizzle-orm';

export async function validateSessionToken(sessionToken: string): Promise<SessionValidationResult> {
  let fresh = false;
  const result = await db.query.sessionTable.findFirst({
    where: eq(sessionTable.id, sessionToken),
    with: {
      user: true,
    },
  });
  if (!result) {
    return { session: null, user: null, fresh };
  }

  const { user, ...session } = result;

  // 如果 session 过期,删除 session
  if (Date.now() >= session.expiresAt.getTime()) {
    await db.delete(sessionTable).where(eq(sessionTable.id, sessionToken));

    return { session: null, user: null, fresh };
  }

  // 如果 session 快要过期,刷新 session
  if (Date.now() >= session.expiresAt.getTime() - SESSION_REFRESH_INTERVAL_MS) {
    session.expiresAt = new Date(Date.now() + SESSION_MAX_DURATION_MS);
    // 设置为 fresh 表示 session 被刷新了
    fresh = true;
    await db.update(sessionTable).set({ expiresAt: session.expiresAt }).where(eq(sessionTable.id, sessionToken));
  }

  return { session, user, fresh };
}

Invalidate Session

import { db } from '@/db';
import { sessionTable } from '@/db/schema';
import { eq } from 'drizzle-orm';

export async function invalidateSession(sessionId: string): Promise<void> {
  await db.delete(sessionTable).where(eq(sessionTable.id, sessionId));
}

lib/lucia/cookie.ts

This part mainly contains the logic for generating cookie.

Because we need to store the session’s token in the cookie.

If you use Nextjs, you can use next/headers to set the cookie.

Basic Types

export interface CookieAttributes {
  secure?: boolean;
  path?: string;
  domain?: string;
  sameSite?: 'lax' | 'strict' | 'none';
  httpOnly?: boolean;
  maxAge?: number;
  expires?: Date;
}

export interface SessionCookie {
  name: string;
  value: string;
  attributes: CookieAttributes;
}

export const SESSION_COOKIE_NAME = 'session';

const cookieOptions: CookieAttributes = {
  httpOnly: true,
  sameSite: 'lax' as const,
  secure: process.env.NODE_ENV === 'production',
  path: '/',
};
export function createCookie(cookieValue: string, expiresAt: Date): SessionCookie {
  return {
    name: SESSION_COOKIE_NAME,
    value: cookieValue,
    attributes: { ...cookieOptions, expires: expiresAt },
  };
}

export function createBlankCookie(): SessionCookie {
  return {
    name: SESSION_COOKIE_NAME,
    value: '',
    attributes: { ...cookieOptions, maxAge: 0 },
  };
}

Conclusion

Isn’t it simple? The author has provided a very detailed guide for migrating Lucia.

https://lucia-auth.com/sessions/migrate-lucia-v3

If you want to integrate third-party login, you can refer to the author’s other library: Arctic https://arcticjs.dev/

Complete code for lucia/index.ts

import { db } from '@/db';
import { sessionTable } from '@/db/schema';
import { Session } from '@/zod/session';
import { SelectUser } from '@/zod/user';
import { eq } from 'drizzle-orm';
import { generateIdFromEntropySize, hashToken } from './crypto';

export type SessionValidationResult =
  | { session: Session; user: SelectUser; fresh: boolean }
  | { session: null; user: null; fresh: boolean };

const SESSION_REFRESH_INTERVAL_MS = 1000 * 60 * 60 * 24 * 15; // 15 days
const SESSION_MAX_DURATION_MS = SESSION_REFRESH_INTERVAL_MS * 2; // 30 days

export function generateSessionToken(): string {
  const token = generateIdFromEntropySize(20);
  return hashToken(token);
}

export async function createSession(sessionToken: string, userId: string): Promise<Session> {
  const session = {
    id: sessionToken,
    userId,
    expiresAt: new Date(Date.now() + SESSION_MAX_DURATION_MS),
  };
  const [dbSession] = await db.insert(sessionTable).values(session).returning();
  return dbSession;
}

export async function validateSessionToken(sessionToken: string): Promise<SessionValidationResult> {
  let fresh = false;
  const result = await db.query.sessionTable.findFirst({
    where: eq(sessionTable.id, sessionToken),
    with: {
      user: true,
    },
  });
  if (!result) {
    return { session: null, user: null, fresh };
  }

  const { user, ...session } = result;

  // if the session is expired, delete it
  if (Date.now() >= session.expiresAt.getTime()) {
    await db.delete(sessionTable).where(eq(sessionTable.id, sessionToken));

    return { session: null, user: null, fresh };
  }

  // if the session is about to expire, refresh it
  if (Date.now() >= session.expiresAt.getTime() - SESSION_REFRESH_INTERVAL_MS) {
    session.expiresAt = new Date(Date.now() + SESSION_MAX_DURATION_MS);
    fresh = true;
    await db.update(sessionTable).set({ expiresAt: session.expiresAt }).where(eq(sessionTable.id, sessionToken));
  }
  return { session, user, fresh };
}

export async function invalidateSession(sessionId: string): Promise<void> {
  await db.delete(sessionTable).where(eq(sessionTable.id, sessionId));
}

参考

扫码_搜索联合传播样式-标准色版