Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Next.JS Google OAuth Example #33

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions nextjs-app/google-oauth/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
3 changes: 3 additions & 0 deletions nextjs-app/google-oauth/.example.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
HOST_NAME="http://localhost:3000"
40 changes: 40 additions & 0 deletions nextjs-app/google-oauth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

main.db

.env
26 changes: 26 additions & 0 deletions nextjs-app/google-oauth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# GitHub OAuth example in Next.js App router

Uses SQLite (`main.db`) database.

```
pnpm i
pnpm dev
```

## Setup

Create a GitHub OAuth app with the callback set to `http://localhost:3000/login/github/callback` and create an `.env` file.

```bash
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
```

## Polyfill

If you're using Node 16 or 18, uncomment the code in `lib/auth.ts`. This is not required in Node 20, Bun, and Cloudflare Workers.

```ts
// import { webcrypto } from "crypto";
// globalThis.crypto = webcrypto as Crypto;
```
13 changes: 13 additions & 0 deletions nextjs-app/google-oauth/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Lucia example"
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
106 changes: 106 additions & 0 deletions nextjs-app/google-oauth/app/login/google/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@

import { cookies } from "next/headers";
import { OAuth2RequestError } from "arctic";
import { DatabaseUser, db } from "@/lib/db";
import { generateId } from "lucia";
import { googleAuth, lucia } from "@/lib/auth";

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("google_oauth_state")?.value ?? null;
const codeVerifier = cookies().get("google_code_verifier")?.value ?? null;

if (
!code ||
!state ||
!storedState ||
state !== storedState ||
!codeVerifier
) {
return new Response(null, {
status: 400,
});
}

try {
const tokens = await googleAuth.validateAuthorizationCode(
code,
codeVerifier
);
const response = await fetch(
"https://openidconnect.googleapis.com/v1/userinfo",
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
}
);
const googleUser: GoogleUser = await response.json();

const existingUser = db.prepare("SELECT * FROM user WHERE google_id = ?").get(googleUser.sub) as
| DatabaseUser
| undefined;

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);
db.prepare("INSERT INTO user (id, google_id, username) VALUES (?, ?, ?)").run(
userId,
googleUser.sub,
googleUser.name
);

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) {
console.log(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 GoogleUser {
sub: string;
name: string;
given_name: string;
family_name: string;
picture: string;
email: string;
email_verified: boolean;
locale: string;
}
27 changes: 27 additions & 0 deletions nextjs-app/google-oauth/app/login/google/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { cookies } from "next/headers";
import { generateCodeVerifier, generateState } from "arctic";
import { googleAuth } from "@/lib/auth";

export async function GET(): Promise<Response> {
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = await googleAuth.createAuthorizationURL(state, codeVerifier, {
scopes: ["profile", "email"],
});

cookies().set("google_oauth_state", state, {
secure: true,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be secure: process.env.NODE_ENV === "production" for localhost testing

path: "/",
httpOnly: true,
maxAge: 60 * 10,
});

cookies().set("google_code_verifier", codeVerifier, {
secure: true,
path: "/",
httpOnly: true,
maxAge: 60 * 10,
});

return Response.redirect(url);
}
15 changes: 15 additions & 0 deletions nextjs-app/google-oauth/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { validateRequest } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function Page() {
const { user } = await validateRequest();
if (user) {
return redirect("/");
}
return (
<>
<h1>Sign in</h1>
<a href="/login/google">Sign in with Google</a>
</>
);
}
39 changes: 39 additions & 0 deletions nextjs-app/google-oauth/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { lucia, validateRequest } from "@/lib/auth";
import { redirect } from "next/navigation";
import { cookies } from "next/headers";

export default async function Page() {
const { user } = await validateRequest();
if (!user) {
return redirect("/login");
}
return (
<>
<h1>Hi, {user.username}!</h1>
<p>Your user ID is {user.id}.</p>
<form action={logout}>
<button>Sign out</button>
</form>
</>
);
}

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;
}
68 changes: 68 additions & 0 deletions nextjs-app/google-oauth/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Lucia } from "lucia";
import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite";
import { db } from "./db";
import { cookies } from "next/headers";
import { cache } from "react";
import { Google } from "arctic";

import type { Session, User } from "lucia";
import type { DatabaseUser } from "./db";

const adapter = new BetterSqlite3Adapter(db, {
user: "user",
session: "session"
});

export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === "production"
}
},
getUserAttributes: (attributes) => {
return {
githubId: attributes.google_id,
username: attributes.username
};
}
});

declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: Omit<DatabaseUser, "id">;
}
}

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);
// next.js throws when you attempt to set cookie when rendering page
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;
}
);


export const googleAuth = new Google(
process.env.GOOGLE_CLIENT_ID!,
process.env.GOOGLE_CLIENT_SECRET!,
`${process.env.HOST_NAME}/login/google/callback`
);
22 changes: 22 additions & 0 deletions nextjs-app/google-oauth/lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import sqlite from "better-sqlite3";

export const db = sqlite("main.db");

db.exec(`CREATE TABLE IF NOT EXISTS user (
id TEXT NOT NULL PRIMARY KEY,
google_id INTEGER NOT NULL UNIQUE,
username TEXT NOT NULL
)`);

db.exec(`CREATE TABLE IF NOT EXISTS session (
id TEXT NOT NULL PRIMARY KEY,
expires_at INTEGER NOT NULL,
user_id TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id)
)`);

export interface DatabaseUser {
id: string;
username: string;
google_id: number;
}
8 changes: 8 additions & 0 deletions nextjs-app/google-oauth/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ["oslo"]
}
};

module.exports = nextConfig;
Loading