Created
April 3, 2024 22:52
-
-
Save praskoson/a74d1bc1b099597fbfc3760518d9f016 to your computer and use it in GitHub Desktop.
Twitter OAuth with Lucia
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | |
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}; | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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