GitHub OAuth in Nuxt

Before starting, make sure you’ve setup Lucia and your database.

This guide will cover how to implement GitHub OAuth using Lucia in Nuxt. It will have 3 parts:

  • A sign up page
  • An endpoint to authenticate users with GitHub
  • A profile page with a logout button

As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user’s identity.

Clone project#

You can get started immediately by cloning the Nuxt example from the repository.

npx degit lucia-auth/examples/nuxt/github-oauth <directory_name>

Alternatively, you can open it in StackBlitz.

Create an OAuth app#

Create a GitHub OAuth app. Set the redirect uri to:

http://localhost:3000/api/login/github/callback

Copy and paste the client id and client secret into your .env file:

# .env
NUXT_GITHUB_CLIENT_ID="..."
NUXT_GITHUB_CLIENT_SECRET="..."

Expose the environment variables by updating your Nuxt config.

// nuxt.config.ts
export default defineNuxtConfig({
	// ...
	runtimeConfig: {
		// keep these empty!
		githubClientId: "",
		githubClientSecret: ""
	}
	// When using node < 20 we need to uncomment the following section in order to polyfill the Web Crypto API.
	// nitro: {
	//   moduleSideEffects: ["lucia/polyfill/node"]
	// },
});

Update your database#

Add a username column to your table. It should be a string (TEXT, VARCHAR etc) type (optionally unique).

Make sure you update Lucia.DatabaseUserAttributes whenever you add any new columns to the user table.

// server/app.d.ts

/// <reference types="lucia" />
declare namespace Lucia {
	type Auth = import("./utils/lucia").Auth;
	type DatabaseUserAttributes = {
		username: string;
	};
	type DatabaseSessionAttributes = {};
}

Configure Lucia#

We’ll expose the user’s GitHub username to the User object by defining getUserAttributes.

// server/utils/lucia.ts
import { lucia } from "lucia";
import { h3 } from "lucia/middleware";
// When using node < 20 uncomment the following line.
// import 'lucia/polyfill/node'

export const auth = lucia({
	adapter: ADAPTER,
	env: process.dev ? "DEV" : "PROD",
	middleware: h3(),

	getUserAttributes: (data) => {
		return {
			githubUsername: data.username
		};
	}
});

export type Auth = typeof auth;

Initialize the OAuth integration#

Install the OAuth integration.

npm i @lucia-auth/oauth
pnpm add @lucia-auth/oauth
yarn add @lucia-auth/oauth

Import the GitHub OAuth integration, and initialize it using your credentials.

// server/utils/lucia.ts
import { lucia } from "lucia";
import { h3 } from "lucia/middleware";

import { github } from "@lucia-auth/oauth/providers";

export const auth = lucia({
	// ...
});

const runtimeConfig = useRuntimeConfig();

export const githubAuth = github(auth, {
	clientId: runtimeConfig.githubClientId,
	clientSecret: runtimeConfig.githubClientSecret
});

export type Auth = typeof auth;

Sign in page#

Create pages/login.vue. It will have a “Sign in with GitHub” button (actually a link).

<!-- pages/login.vue -->
<template>
	<h1>Sign in</h1>
	<a href="/api/login/github">Sign in with GitHub</a>
</template>

When a user clicks the link, the destination (/api/login/github) will redirect the user to GitHub to be authenticated.

Generate authorization url#

Create server/api/login/github/index.get.ts. GithubProvider.getAuthorizationUrl() will create a new GitHub authorization url, where the user will be authenticated in github.com. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later.

// server/api/login/github/index.get.ts
export default defineEventHandler(async (event) => {
	const [url, state] = await githubAuth.getAuthorizationUrl();
	setCookie(event, "github_oauth_state", state, {
		httpOnly: true,
		secure: !process.dev,
		path: "/",
		maxAge: 60 * 60
	});
	return sendRedirect(event, url.toString());
});

Validate callback#

Create server/api/login/github/callback.get.ts

When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with GithubProvider.validateCallback(). This will return GithubUserAuth if the code is valid, or throw an error if not.

After successfully creating a user, we’ll create a new session with Auth.createSession() and store it as a cookie with AuthRequest.setSession(). AuthRequest can be created by calling Auth.handleRequest() with H3Event.

// server/api/login/github/callback.get.ts
import { OAuthRequestError } from "@lucia-auth/oauth";

export default defineEventHandler(async (event) => {
	const storedState = getCookie(event, "github_oauth_state");
	const query = getQuery(event);
	const state = query.state?.toString();
	const code = query.code?.toString();
	// validate state
	if (!storedState || !state || storedState !== state || !code) {
		return sendError(
			event,
			createError({
				statusCode: 400
			})
		);
	}
	try {
		const { getExistingUser, githubUser, createUser } =
			await githubAuth.validateCallback(code);

		const getUser = async () => {
			const existingUser = await getExistingUser();
			if (existingUser) return existingUser;
			const user = await createUser({
				attributes: {
					username: githubUser.login
				}
			});
			return user;
		};

		const user = await getUser();
		const session = await auth.createSession({
			userId: user.userId,
			attributes: {}
		});
		const authRequest = auth.handleRequest(event);
		authRequest.setSession(session);
		return sendRedirect(event, "/"); // redirect to profile page
	} catch (e) {
		if (e instanceof OAuthRequestError) {
			// invalid code
			return sendError(
				event,
				createError({
					statusCode: 400
				})
			);
		}
		return sendError(
			event,
			createError({
				statusCode: 500
			})
		);
	}
});

Authenticate user with Lucia#

You can check if the user has already registered with your app by checking GithubUserAuth.getExistingUser. Internally, this is done by checking if a key with the GitHub user id already exists.

If they’re a new user, you can create a new Lucia user (and key) with GithubUserAuth.createUser(). The type for attributes property is Lucia.DatabaseUserAttributes, which we added username to previously. You can access the GitHub user data with GithubUserAuth.githubUser, as well as the access tokens with GithubUserAuth.githubTokens.

const { getExistingUser, githubUser, createUser } =
	await githubAuth.validateCallback(code);

const getUser = async () => {
	const existingUser = await getExistingUser();
	if (existingUser) return existingUser;
	const user = await createUser({
		attributes: {
			username: githubUser.login
		}
	});
	return user;
};

const user = await getUser();

Managing auth state#

Get authenticated user#

Create server/api/user.get.ts. This endpoint will return the current user. You can validate requests by creating by calling AuthRequest.validate(). This method returns a Session if the user is authenticated or null if not.

// server/api/user.get.ts
export default defineEventHandler(async (event) => {
	const authRequest = auth.handleRequest(event);
	const session = await authRequest.validate();
	return {
		user: session?.user ?? null;
	}
});

Composables#

Create useUser() and useAuthenticatedUser() composables. useUser() will return the user state. useAuthenticatedUser() can only be used inside protected routes, which allows the ref value type to be always defined (never null).

// composables/auth.ts
import type { User } from "lucia";

export const useUser = () => {
	const user = useState<User | null>("user", () => null);
	return user;
};

export const useAuthenticatedUser = () => {
	const user = useUser();
	return computed(() => {
		const userValue = unref(user);
		if (!userValue) {
			throw createError(
				"useAuthenticatedUser() can only be used in protected pages"
			);
		}
		return userValue;
	});
};

Define middleware#

Define a global auth middleware that gets the current user and populates the user state. This will run on every navigation.

// middleware/auth.global.ts
export default defineNuxtRouteMiddleware(async () => {
	const user = useUser();
	const { data, error } = await useFetch("/api/user");
	if (error.value) throw createError("Failed to fetch data");
	user.value = data.value?.user ?? null;
});

Next, define a regular protected middleware that redirects unauthenticated users to the login page.

// middleware/protected.ts
export default defineNuxtRouteMiddleware(async () => {
	const user = useUser();
	if (!user.value) return navigateTo("/login");
});

Redirect authenticated user#

Redirect authenticated users to the profile page in pages/login.vue.

<!-- pages/login.vue -->
<script lang="ts" setup>
const user = useUser();
if (user.value) {
	await navigateTo("/"); // redirect to profile page
}

const handleSubmit = async (e: Event) => {
	// ...
};
</script>

Profile page#

Create pages/index.vue. This will show some basic user info and include a logout button.

Use the protected middleware to redirect unauthenticated users, and call useAuthenticatedUser() to get the authenticated user.

<!-- pages/index.vue -->
<script lang="ts" setup>
definePageMeta({
	middleware: ["protected"]
});

const user = useAuthenticatedUser();

const handleSubmit = async (e: Event) => {
	if (!(e.target instanceof HTMLFormElement)) return;
	await $fetch("/api/login", {
		method: "POST",
		body: formData,
		redirect: "manual" // ignore redirect responses
	});
	await navigateTo("/login");
};
</script>

<template>
	<h1>Profile</h1>
	<p>User id: {{ user.userId }}</p>
	<p>GitHub username: {{ user.githubUsername }}</p>
	<form method="post" action="/api/logout" @submit.prevent="handleSubmit">
		<input type="submit" value="Sign out" />
	</form>
</template>

Sign out users#

Create server/api/logout.post.ts.

When logging out users, it’s critical that you invalidate the user’s session. This can be achieved with Auth.invalidateSession(). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing null to Auth.createSessionCookie().

// server/api/logout.post.ts
export default defineEventHandler(async (event) => {
	const authRequest = auth.handleRequest(event);
	// check if user is authenticated
	const session = await authRequest.validate();
	if (!session) {
		throw createError({
			message: "Unauthorized",
			statusCode: 401
		});
	}
	// make sure to invalidate the current session!
	await auth.invalidateSession(session.sessionId);
	// delete session cookie
	authRequest.setSession(null);
	return sendRedirect(event, "/login");
});