🐍

Intégrer l'Authentification Lucia dans Next.js

Explorez Lucia, une librairie d'authentification flexible pour Node.js et plus, simplifiant la gestion des sessions dans tout environnement de runtime.

Intégrer l'Authentification Lucia dans Next.js

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 matiè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

Ensuite, crĂ©e tes identifiants pour l’application OAuth 2.0.

OauhtClient

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