Lucia est une librairie dâauthentification qui simplifie la gestion des sessions et qui fonctionne dans nâimporte quel environnement de runtime, comme Node.js, Cloudflare Workers, etc.
Table of contents
Ouvrir la table des matieÌres
Mettre en place le projet
Pour commencer, créons une nouvelle application Next.js avec la commande suivante :
pnpx create-next-app@latest
En mĂȘme temps, nous allons installer des librairies dont nous aurons besoin pour le projet
pnpm i lucia oslo arctic drizzle-orm @lucia-auth/adapter-drizzle @neondatabase/serverless && pnpm add -D drizzle-kit
Drizzle
Maintenant que nous avons installĂ© les librairies, nous allons commencer le projet en crĂ©ant une base de donnĂ©es et les tables pour les utilisateurs et les sessions. De mon cĂŽtĂ©, jâutilise Neon comme base de donnĂ©es serverless.
// db/index.ts
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import { pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core";
const sql = neon(process.env.DATABASE_URL!);
const db = drizzle(sql);
export const userTable = pgTable("users", {
id: text("id").primaryKey(),
avatar: text("avatar"),
username: text("username"),
email: varchar("email", {
length: 255,
}),
});
export const sessionTable = pgTable("session", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => userTable.id),
expiresAt: timestamp("expires_at", {
withTimezone: true,
mode: "date",
}).notNull(),
});
export const adapter = new DrizzlePostgreSQLAdapter(
db,
sessionTable,
userTable
);
Ensuite, nous aurons besoin dâun fichier de configuration pour Drizzle-Kit Ă la racine du projet :
// drizzle.config.ts
import { Config } from "drizzle-kit";
export default {
schema: "./db/*.ts",
out: "./drizzle",
driver: "pg",
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;
Maintenant que nous avons nos tables, nous pouvons migrer la base de données avec la commande :
pnpm drizzle-kit push:pg
Google OAuth2.0.0
Pour cette étape, il est nécessaire de créer une nouvelle application depuis ton compte Google Cloud Console. Commence par créer un nouveau projet puis configure ton écran de consentement OAuth.
![OauhtConsent](/_astro/oauth-consent.CtlZsFwm_1i7JfF.webp)
Ensuite, crĂ©e tes identifiants pour lâapplication OAuth 2.0.
![OauhtClient](/_astro/oauth-client.CI5uncWm_ZfMbnC.webp)
Actuellement, nous avons dĂ©jĂ configurĂ© notre base de donnĂ©es, nos tables, et crĂ©Ă© un projet Google OAuth 2.0, en plus dâavoir obtenu nos clĂ©s API.
Lucia
Nous allons configurer Lucia pour notre projet en important lâadapter que nous avons crĂ©Ă© lors de la configuration de nos tables. Nous crĂ©erons ensuite un type DatabaseUserAttributes
avec les attributs de notre base de donnĂ©es tels que lâemail et lâavatar, que Lucia nâexpose pas par dĂ©faut. Nous initialiserons ensuite le provider Google avec nos clĂ©s API GOOGLE_CLIENT_ID
et GOOGLE_CLIENT_SECRET
, ainsi que lâURL de callback, qui dans mon cas est http://localhost:3000/login/google/callback
.
// app/login/google/route.ts
import { adapter } from "@/db";
import { Google } from "arctic";
import { Lucia } from "lucia";
export const lucia = new Lucia(adapter, {
sessionCookie: {
expires: false,
attributes: {
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
},
},
getUserAttributes: attributes => {
return {
// type de DatabaseUserAttributes
id: attributes.id,
username: attributes.username,
avatar: attributes.avatar,
email: attributes.email,
};
},
});
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
id: number;
username: string;
avatar: string;
email: string;
}
export const gogole = new Google(
process.env.GOOGLE_CLIENT_ID!,
process.env.GOOGLE_CLIENT_SECRET!,
process.env.GOOGLE_CALLBACK_URL!
);
Ensuite, nous pouvons créer une page de connexion avec un simple bouton pour se connecter avec Google :
// app/login/page.tsx
import Link from "next/link";
export default function Login() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<Link href={"/login/google"}>Se connecter avec Google</Link>
</main>
);
}
Authorization Url
AprĂšs cela, il est nĂ©cessaire de crĂ©er une API pour lâauthentification ici app/login/google/route.ts
. Nous gĂ©nĂ©rerons un nouveau state et un code verifier, puis nous crĂ©erons une nouvelle URL dâautorisation avec createAuthorizationURL()
en passant le state et le code verifier. Nous redirigerons ensuite lâutilisateur vers cette URL dâautorisation. Lâutilisateur sera amenĂ© sur une page Google qui lui demandera de sâauthentifier.
// app/login/google/route.ts
import { google } from "@/lib/auth";
import { generateCodeVerifier, generateState } from "arctic";
import { cookies } from "next/headers";
export async function GET(): Promise<Response> {
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = await google.createAuthorizationURL(state, codeVerifier);
cookies().set("google_oauth_state", state, {
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 60 * 10,
sameSite: "lax",
});
cookies().set("code_verifier", codeVerifier, {
secure: process.env.NODE_ENV === "production",
path: "/",
httpOnly: true,
maxAge: 60 * 10,
sameSite: "lax",
});
return Response.redirect(url);
}
Valider le callback
Crée une nouvelle API dans app/login/google/callback/route.ts
. Dans un premier temps, on rĂ©cupĂšre le state et le code verifier du cookie ainsi que ceux de lâURL, et on les compare. Si tout est correct, on valide avec la mĂ©thode validateAuthorizationCode()
. Si ce nâest pas le cas, on gĂ©nĂšre une erreur. AprĂšs la validation, on rĂ©cupĂšre le profil utilisateur en utilisant lâaccess token. On vĂ©rifie si lâutilisateur a dĂ©jĂ un compte en vĂ©rifiant si le Google sub et le googleId de lâutilisateur sont prĂ©sents dans la base de donnĂ©es. Si ce nâest pas le cas, on crĂ©e un nouvel utilisateur et, par la mĂȘme occasion, on gĂ©nĂšre de nouveaux tokens et un cookie de session.
import { db, userTable } from "@/db";
import { google, lucia } from "@/lib/auth";
import { OAuth2RequestError } from "arctic";
import { eq } from "drizzle-orm";
import { generateId } from "lucia";
import { cookies } from "next/headers";
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const storedState = cookies().get("state")?.value ?? null;
const storedCodeVerifier = cookies().get("code_verifier")?.value ?? null;
if (!code || !storedState || !storedCodeVerifier || state !== storedState) {
return new Response(null, {
status: 400,
});
}
try {
const tokens = await google.validateAuthorizationCode(
code,
storedCodeVerifier
);
const googleUserResponse = await fetch(
"https://www.googleapis.com/oauth2/v3/userinfo",
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
}
);
const googleUser: GoogleUser = await googleUserResponse.json();
const existingUser = await db.query.users.findFirst({
where: eq(userTable.googleId, googleUser.sub),
});
if (existingUser) {
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
return new Response(null, {
status: 302,
headers: {
Location: "/",
},
});
}
const userId = generateId(15);
await db.insert(userTable).values({
id: userId,
googleId: googleUser.sub,
avatar: googleUser.picture,
username: googleUser.name,
email: googleUser.email,
});
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
return new Response(null, {
status: 302,
headers: {
Location: "/",
},
});
} catch (e) {
if (e instanceof OAuth2RequestError) {
return new Response(null, {
status: 400,
});
}
return new Response(null, {
status: 500,
});
}
}
type GoogleUser = {
sub: string;
name: string;
email: string;
picture: string;
};
Valider la requete
On vérifie si le cookie de session est valide et on définit un nouveau cookie si nécessaire. Nous enveloppons notre fonction avec cache()
pour Ă©viter des requĂȘtes inutiles Ă la base de donnĂ©es :
import { cookies } from "next/headers";
import { cache } from "react";
import type { Session, User } from "lucia";
import { lucia } from "./auth";
export const validateRequest = cache(
async (): Promise<
{ user: User; session: Session } | { user: null; session: null }
> => {
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) {
return {
user: null,
session: null,
};
}
const result = await lucia.validateSession(sessionId);
try {
if (result.session && result.session.fresh) {
const sessionCookie = lucia.createSessionCookie(result.session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
}
if (!result.session) {
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
}
} catch {}
return result;
}
);
Nous pouvons utiliser cette fonction dans notre page dâaccueil :
import { validateRequest } from "@/lib/validate-request";
import { redirect } from "next/navigation";
export default async function Home() {
const { user } = await validateRequest();
if (!user) {
return redirect("/login");
}
const { username } = user;
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<h1>Coucou {username}</h1>
</main>
);
}
DĂ©connexion
Pour dĂ©connecter lâutilisateur, on invalide la session en utilisant lucia.invalidateSession()
afin de sâassurer que la session est bien supprimĂ©e. Ensuite, on met en place un cookie vide avec lucia.createBlankSessionCookie()
.
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { lucia } from "./auth";
import { validateRequest } from "./validate-request";
async function logout(): Promise<ActionResult> {
"use server";
const { session } = await validateRequest();
if (!session) {
return {
error: "Unauthorized",
};
}
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
return redirect("/login");
}
interface ActionResult {
error: string | null;
}
Ressources
- Poster de lâarticle Ed Hardie
- Lucia : Lucia Auth
- Aperçu de lâapplication : Tester lâapplication