Skip to content

Instantly share code, notes, and snippets.

@praskoson
Created April 3, 2024 22:52
Show Gist options
  • Save praskoson/a74d1bc1b099597fbfc3760518d9f016 to your computer and use it in GitHub Desktop.
Save praskoson/a74d1bc1b099597fbfc3760518d9f016 to your computer and use it in GitHub Desktop.
Twitter OAuth with Lucia
import { Lucia } from "lucia";
import { NeonHTTPAdapter } from "@lucia-auth/adapter-postgresql";
import { neon } from "@neondatabase/serverless";
import { Twitter } from "arctic";
import { cache } from "react";
import { cookies } from "next/headers";
import { env } from "@/env.mjs";
import type { Session, User } from "lucia";
const sql = neon(process.env.POSTGRES_URL!);
const adapter = new NeonHTTPAdapter(sql, {
user: "auth_user",
session: "user_session",
});
export const lucia = new Lucia(adapter, {
sessionCookie: {
expires: false,
attributes: {
// set to `true` when using HTTPS
secure: process.env.NODE_ENV === "production",
},
},
getUserAttributes: (attributes) => {
return {
twitterId: attributes.twitter_id,
username: attributes.username,
};
},
});
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;
}
);
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
twitter_id: string;
username: string;
}
export const twitter = new Twitter(
env.TWITTER_CLIENT_ID,
env.TWITTER_CLIENT_SECRET,
env.NEXT_PUBLIC_URL + "/login/twitter/callback"
);
import { env } from "@/env.mjs";
import { lucia, twitter } from "@/lib/auth";
import { NeonQueryFunction, neon } from "@neondatabase/serverless";
import { OAuth2RequestError } from "arctic";
import { generateId } from "lucia";
import { cookies } from "next/headers";
export async function GET(request: Request): Promise<Response> {
// notFound();
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
return new Response(null, {
status: 302,
headers: { Location: `/?error=${error}` },
});
}
const storedState = cookies().get("twitter_oauth_state")?.value ?? null;
const storedCodeVerifier = cookies().get("code_verifier")?.value ?? null;
if (
!code ||
!state ||
!storedState ||
!storedCodeVerifier ||
state !== storedState
) {
return new Response(null, {
status: 400,
});
}
try {
const tokens = await twitter.validateAuthorizationCode(
code,
storedCodeVerifier
);
const twitterUserResponse = await fetch(
"https://api.twitter.com/2/users/me?user.fields=public_metrics",
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
}
);
if (twitterUserResponse.status > 299 || twitterUserResponse.status < 200) {
console.error(
`Failed to fetch user data: ${await twitterUserResponse.text()}`
);
return new Response(null, {
status: 400,
statusText: "Failed to fetch user data",
});
}
const twitterUser: TwitterUser = await twitterUserResponse.json();
// Get auth_user
const sql = neon(env.POSTGRES_URL);
const existingUser = (
await sql`SELECT * FROM auth_user WHERE twitter_id = ${twitterUser.data.id}`
)[0];
if (existingUser) {
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
const originPath = cookies().get("origin_path")?.value || "";
cookies().delete("origin_path");
return new Response(null, {
status: 302,
headers: {
Location: `/${originPath}`,
},
});
}
const userId = generateId(15);
// Create auth_user
await sql`
INSERT INTO auth_user (id, twitter_id, username, follower_count, name)
VALUES (
${userId},
${twitterUser.data.id},
${twitterUser.data.username},
${twitterUser.data?.public_metrics?.followers_count ?? 0},
${twitterUser.data.name}
)`;
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
const originPath = cookies().get("origin_path")?.value || "";
cookies().delete("origin_path");
return new Response(null, {
status: 302,
headers: {
Location: `/${originPath}`,
},
});
} catch (e) {
// the specific error message depends on the provider
console.log(e);
if (e instanceof OAuth2RequestError) {
// invalid code
return new Response(null, {
status: 400,
});
}
return new Response(null, {
status: 500,
statusText: JSON.stringify(e),
});
}
}
interface TwitterUser {
data: {
id: string;
name: string;
username: string;
public_metrics: {
followers_count: number;
following_count: number;
tweet_count: number;
listed_count: number;
};
};
}
import { generateCodeVerifier, generateState } from "arctic";
import { twitter } from "@/lib/auth";
import { cookies } from "next/headers";
export async function GET(request: Request): Promise<Response> {
const reqUrl = new URL(request.url);
const originPath = reqUrl.searchParams.get("origin_path");
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = await twitter.createAuthorizationURL(state, codeVerifier, {
scopes: ["users.read", "tweet.read", "tweet.write", "offline.access"],
});
cookies().set("twitter_oauth_state", state, {
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 60 * 10,
sameSite: "lax",
});
cookies().set("code_verifier", codeVerifier, {
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 60 * 10,
});
if (originPath) {
cookies().set("origin_path", originPath, {
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 60 * 10,
});
} else {
cookies().delete("origin_path");
}
return Response.redirect(url);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment