Authentication with Github with Lucia, Astro JS, and Cloudflare D1 Database | @alexweberk
Astro
Authentication with Github with Lucia, Astro JS, and Cloudflare D1 Database
2024-09-23

Authentication with Github with Lucia, Astro JS, and Cloudflare D1 Database

Setting up authentication with Github using Lucia, Astro JS, and Cloudflare D1 Database


In this article, I will go through the steps of how to setup Lucia with Github OAuth using Astro JS and Cloudflare D1 Database. For those of you who’d like to follow along, the entire codebase is available on this GitHub Repo.

Setting up the project

Set up the project by running the following commands:

npm create cloudflare@latest -- astro-cloudflare-lucia --framework=astro

Deploy and checkout the project online.

Now update packages.json so that we can do “npm run deploy” to deploy the project.

"scripts": {
  ...,
  "deploy": "astro build && wrangler pages deploy ./dist",
  ...
}

Edit the src/pages/index.astro file a bit and run npm run deploy to see that changes are reflected in the deployed project.

tsconfig.json Paths

I edited my tsconfig.json paths to the following for convenience.

{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "types": ["@cloudflare/workers-types", "drizzle-orm/d1"],
    "baseUrl": "./",
    "paths": {
      "@components/*": ["./src/components/*"],
      "@lib/*": ["./src/lib/*"],
      "@assets/*": ["./src/assets/*"],
      "@db/*": ["./src/db/*"]
    }
  }
}

Add Cloudflare D1 Database

Create a new D1 database.

npx wrangler d1 create astro-cloudflare-lucia

Copy the resulting code and paste it into wrangler.toml. Create the file if it doesn’t exist.

# wrangler.toml
name = "astro-cloudflare-lucia"
pages_build_output_dir = "./dist"

compatibility_flags = ["nodejs_compat"]
compatibility_date = "2024-09-23"

[[d1_databases]]
binding = "DB"                                       # i.e. available in your Worker on env.DB
database_name = "<<your database name>>"
database_id = "<<your database id>>"

Install Lucia, Drizzle ORM and Drizzle Kit. We’ll use it later.

npm i lucia
npm i drizzle-orm
npm i -D drizzle-kit

Setting up Github OAuth App

Now setup the Github OAuth App.

Go to Github Developer Settings and create a new OAuth App. Click “New OAuth App” and enter details. Homepage URL can be your Cloudflare project URL. Authorization callback URL is your Cloudflare project URL + /login/github/callback.

You can get your Client ID and generate a new Client Secret. Copy them and paste it into your .env file.

# .env
GITHUB_CLIENT_ID="<<your client id>>"
GITHUB_CLIENT_SECRET="<<your client secret>>"

Also, login to Cloudflare and navigate to your project. Under “Settings > Variables and Secrets” add the above secrets there, too.

Setting up Lucia

Lucia’s docs are here but they are hard to follow.

First step, paste middleware code in src/middleware.ts.

// src/middleware.ts
import { initializeLucia } from "@lib/auth";
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (context, next) => {
  const lucia = initializeLucia(context.locals.runtime.env.DB);
  const sessionId = context.cookies.get(lucia.sessionCookieName)?.value ?? null;
  if (!sessionId) {
    context.locals.user = null;
    context.locals.session = null;
    return next();
  }

  const { session, user } = await lucia.validateSession(sessionId);
  if (session && session.fresh) {
    const sessionCookie = lucia.createSessionCookie(session.id);
    context.cookies.set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes
    );
  }
  if (!session) {
    const sessionCookie = lucia.createBlankSessionCookie();
    context.cookies.set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes
    );
  }
  context.locals.session = session;
  context.locals.user = user;
  return next();
});

Now install Drizzle ORM and Arctic.

npm i @lucia-auth/adapter-drizzle
npm i arctic

In src/lib/auth.ts add:

// src/lib/auth.ts
import { sessionTable, userTable } from "@db/schema";
import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle";
import { drizzle } from "drizzle-orm/d1";
import { Lucia } from "lucia";

import { GitHub } from "arctic";

export function initializeLucia(D1: D1Database) {
  const db = drizzle(D1);
  const adapter = new DrizzleSQLiteAdapter(db, sessionTable, userTable);

  return new Lucia(adapter, {
    sessionCookie: {
      attributes: {
        secure: false,
      },
    },
    getUserAttributes: (attributes: DatabaseUserAttributes) => {
      return {
        githubId: attributes.github_id,
        username: attributes.username,
      };
    },
  });
}

export function initializeGithubClient(env: Env) {
  return new GitHub(env.GITHUB_CLIENT_ID, env.GITHUB_CLIENT_SECRET);
}

interface DatabaseUserAttributes {
  github_id: number;
  username: string;
}

declare module "lucia" {
  interface Register {
    Lucia: ReturnType<typeof initializeLucia>;
    DatabaseUserAttributes: DatabaseUserAttributes;
  }
}

Edit your src/env.d.ts file to the following:

// src/env.d.ts
/// <reference path="../.astro/types.d.ts" />

type D1Database = import("@cloudflare/workers-types").D1Database;
type Env = {
  DB: D1Database;
  GITHUB_CLIENT_ID: string;
  GITHUB_CLIENT_SECRET: string;
};
type Runtime = import("@astrojs/cloudflare").Runtime<Env>;

declare namespace App {
  interface Locals extends Runtime {
    session: import("lucia").Session | null;
    user: import("lucia").User | null;
  }
}

Setup Database Schema and Migrations

We’ll setup the schema for database tables in src/db/schema.ts:

// src/db/schema.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const userTable = sqliteTable("users", {
  id: text("id").notNull().primaryKey(),
  githubId: integer("github_id").unique(),
  username: text("username"),
});

export const sessionTable = sqliteTable("sessions", {
  id: text("id").notNull().primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() => userTable.id),
  expiresAt: integer("expires_at").notNull(),
});

Now add the following scripts to package.json to generate and apply migrations, if you find them hard to remember.

"scripts": {
  ...,
  "db:generate": "drizzle-kit generate --dialect=sqlite --schema=./src/db/schema.ts --out=./src/db/migrations",
  "db:migrate:local": "wrangler d1 migrations apply astro-cloudflare-lucia --local",
  "db:migrate:remote": "wrangler d1 migrations apply astro-cloudflare-lucia --remote",
  ...,
}

First run the db:generate script to generate your migration file. You will find files generated in the src/db/migrations folder.

Now run the db:migrate:local and db:migrate:remote scripts to apply the migration to your local D1 database and to the remote D1 database, respectively.

npm run db:migrate:local
npm run db:migrate:remote

Now your databases have your tables in them.

Create login page and Login with Github API

Create src/pages/login/index.astro with the following:

---
// src/pages/login/index.astro
---
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>Login</title>
  </head>
  <body>
    <h1>Login</h1>
    <a href="/login/github">Login with GitHub</a>
  </body>
</html>

And create src/pages/login/github/index.ts with the following:

// src/pages/login/github/index.ts
import { initializeGithubClient } from "@lib/auth";
import { generateState } from "arctic";

import type { APIContext } from "astro";

export async function GET(context: APIContext): Promise<Response> {
  const github = initializeGithubClient(context.locals.runtime.env);
  const state = generateState();
  const url = await github.createAuthorizationURL(state);

  context.cookies.set("github_oauth_state", state, {
    path: "/",
    secure: import.meta.env.PROD,
    httpOnly: true,
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  return context.redirect(url.toString());
}

Then, create src/pages/login/github/callback.ts with the following:

// src/pages/login/github/callback.ts
import { userTable } from "@db/schema";
import { initializeGithubClient, initializeLucia } from "@lib/auth";
import { OAuth2RequestError } from "arctic";
import type { APIContext } from "astro";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { generateIdFromEntropySize } from "lucia";

export async function GET(context: APIContext): Promise<Response> {
  const github = initializeGithubClient(context.locals.runtime.env);
  const lucia = initializeLucia(context.locals.runtime.env.DB);
  const db = drizzle(context.locals.runtime.env.DB);

  const code = context.url.searchParams.get("code");
  const state = context.url.searchParams.get("state");
  const storedState = context.cookies.get("github_oauth_state")?.value ?? null;
  if (!code || !state || !storedState || state !== storedState) {
    return new Response(null, {
      status: 400,
    });
  }

  try {
    const tokens = await github.validateAuthorizationCode(code);
    const githubUserResponse = await fetch("https://api.github.com/user", {
      headers: {
        Authorization: `Bearer ${tokens.accessToken}`,
        "User-Agent": "astro-cloudflare-lucia",
      },
    });
    const githubUser: GitHubUser = await githubUserResponse.json();

    // Replace this with your own DB client.
    const existingUser = await db
      .select()
      .from(userTable)
      .where(eq(userTable.githubId, Number(githubUser.id)))
      .get();

    if (existingUser) {
      const session = await lucia.createSession(existingUser.id, {});
      const sessionCookie = lucia.createSessionCookie(session.id);
      context.cookies.set(
        sessionCookie.name,
        sessionCookie.value,
        sessionCookie.attributes
      );
      return context.redirect("/");
    }

    const userId = generateIdFromEntropySize(10); // 16 characters long

    // Replace this with your own DB client.
    await db.insert(userTable).values({
      id: userId,
      githubId: Number(githubUser.id),
      username: githubUser.login,
    });

    const session = await lucia.createSession(userId, {});
    const sessionCookie = lucia.createSessionCookie(session.id);
    context.cookies.set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes
    );
    return context.redirect("/");
  } catch (e) {
    // the specific error message depends on the provider
    if (e instanceof OAuth2RequestError) {
      // invalid code
      return new Response(null, {
        status: 400,
      });
    }
    return new Response(null, {
      status: 500,
    });
  }
}

interface GitHubUser {
  id: string;
  login: string;
}

Create Logout API

Create src/pages/logout.ts with the following:

// src/pages/logout.ts
import { initializeLucia } from "@lib/auth";
import type { APIContext } from "astro";

export async function POST(context: APIContext): Promise<Response> {
  if (!context.locals.session) {
    return new Response(null, {
      status: 401,
    });
  }

  const lucia = initializeLucia(context.locals.runtime.env.DB);

  await lucia.invalidateSession(context.locals.session.id);

  const sessionCookie = lucia.createBlankSessionCookie();
  context.cookies.set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );

  return context.redirect("/login");
}

Modify the Home Page

Let’s tie it together by modifying the home page with the following:

---
// src/pages/index.astro
import Layout from "../layouts/Layout.astro";
const now = new Date();

// We can now get the user object from Astro.locals if the user is logged in.
const { user } = Astro.locals;
---

<Layout title="Welcome to Astro.">
  <main>
    <h1>Welcome to Astro.</h1>
    <p>The current time is {now.toLocaleTimeString()}.</p>
    {
      user ? (
        <div>
          <p>You are logged in as {JSON.stringify(user)}.</p>
          <form
            action="/logout"
            method="post"
          >
            <button type="submit">Logout</button>
          </form>
        </div>
      ) : (
        <a href="/login">Login</a>
      )
    }
  </main>
</Layout>

Deploy the Project

npm run deploy

Now you can navigate to the project and test it out. You should be able to login with Github and see the user object in the home page. You should also be able to logout.

Conclusion

So this was the summary of the steps to setup login with Github using Lucia and Astro. There are other providers you can use, too.

I found the docs for Lucia and Arctic hard to follow, so I hope this helps others who are trying to do the same.

Enormous thanks to this YouTube video: YouTube Video

Enjoyed this article?

Follow me on X/Twitter for more articles like this!

Follow on X/Twitter