From cd0b01df955b8a35dd9ccca3462fc8d162c5b3c0 Mon Sep 17 00:00:00 2001 From: saiteja-in Date: Wed, 16 Oct 2024 13:08:01 +0530 Subject: [PATCH] migrating from lucia to artic and oslo --- README.md | 2 +- package-lock.json | 54 ++++++++---- package.json | 4 +- src/app/api/sign-out/route.ts | 15 +--- src/app/layout.tsx | 2 +- src/db/schema.ts | 1 + src/lib/auth.ts | 153 +++++++++++++++++++--------------- src/lib/session.ts | 48 +++++++---- 8 files changed, 161 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 3dcb609..40aee5c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is a Next.js template which includes the following technology - next.js (app router) - drizzle orm -- lucia auth +- auth with artic and oslo - sqlite (or turso) - shadcn - react hook form diff --git a/package-lock.json b/package-lock.json index 55b66fd..b8145f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@hookform/resolvers": "^3.4.2", "@libsql/client": "0.6.1", - "@lucia-auth/adapter-drizzle": "^1.0.7", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.1.0", @@ -23,7 +24,6 @@ "clsx": "2.1.1", "dotenv": "16.4.5", "drizzle-orm": "0.30.10", - "lucia": "3.2.0", "lucide-react": "0.381.0", "next": "14.2.3", "next-themes": "0.3.0", @@ -41,6 +41,7 @@ }, "devDependencies": { "@tailwindcss/typography": "0.5.13", + "@total-typescript/ts-reset": "^0.5.1", "@types/node": "20.14.0", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", @@ -1233,14 +1234,6 @@ "win32" ] }, - "node_modules/@lucia-auth/adapter-drizzle": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@lucia-auth/adapter-drizzle/-/adapter-drizzle-1.0.7.tgz", - "integrity": "sha512-X/V7fLBca8EC/gPXCntwbQpb0+F9oEuRoHElvsi9rCrdnGhCMNxHgwAvgiQ6pes+rIYpyvx4n3hvjqo/fPo03A==", - "peerDependencies": { - "lucia": "3.x" - } - }, "node_modules/@neon-rs/load": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", @@ -1749,6 +1742,33 @@ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" }, + "node_modules/@oslojs/asn1": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", + "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==", + "dependencies": { + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/binary": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz", + "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==" + }, + "node_modules/@oslojs/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==", + "dependencies": { + "@oslojs/asn1": "1.0.0", + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2837,6 +2857,12 @@ "tailwindcss": ">=3.0.0 || insiders" } }, + "node_modules/@total-typescript/ts-reset": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.5.1.tgz", + "integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==", + "dev": true + }, "node_modules/@types/better-sqlite3": { "version": "7.6.10", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.10.tgz", @@ -6653,14 +6679,6 @@ "es5-ext": "~0.10.2" } }, - "node_modules/lucia": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/lucia/-/lucia-3.2.0.tgz", - "integrity": "sha512-eXMxXwk6hqtjRTj4W/x3EnTUtAztLPm0p2N2TEBMDEbakDLXiYnDQ9z/qahjPdPdhPguQc+vwO0/88zIWxlpuw==", - "dependencies": { - "oslo": "1.2.0" - } - }, "node_modules/lucide-react": { "version": "0.381.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.381.0.tgz", diff --git a/package.json b/package.json index 3a5f032..b999191 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "dependencies": { "@hookform/resolvers": "^3.4.2", "@libsql/client": "0.6.1", - "@lucia-auth/adapter-drizzle": "^1.0.7", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.1.0", @@ -28,7 +29,6 @@ "clsx": "2.1.1", "dotenv": "16.4.5", "drizzle-orm": "0.30.10", - "lucia": "3.2.0", "lucide-react": "0.381.0", "next": "14.2.3", "next-themes": "0.3.0", diff --git a/src/app/api/sign-out/route.ts b/src/app/api/sign-out/route.ts index 8a99dc4..395143d 100644 --- a/src/app/api/sign-out/route.ts +++ b/src/app/api/sign-out/route.ts @@ -1,5 +1,4 @@ -import { lucia, validateRequest } from "@/lib/auth"; -import { cookies } from "next/headers"; +import { invalidateSession, validateRequest } from "@/lib/auth"; import { redirect } from "next/navigation"; export async function GET(): Promise { @@ -9,13 +8,7 @@ export async function GET(): Promise { if (!session) { redirect("/sign-in"); } - - await lucia.invalidateSession(session.id); - const sessionCookie = lucia.createBlankSessionCookie(); - cookies().set( - sessionCookie.name, - sessionCookie.value, - sessionCookie.attributes, - ); + await invalidateSession(session.id); + redirect("/signed-out"); -} +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 675b5a6..c556802 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -27,7 +27,7 @@ export const metadata: Metadata = { { rel: "icon", type: "image/png", sizes: "48x48", url: "/favicon.ico" }, ], keywords: "yolo", - description: "A simple next.js template including drizzle and lucia auth", + description: "A simple next.js template including drizzle and auth with artic and oslo", }; export default async function RootLayout({ diff --git a/src/db/schema.ts b/src/db/schema.ts index 9405fef..5c7897e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -74,3 +74,4 @@ export const sessions = sqliteTable("session", { export type User = typeof users.$inferSelect; export type Profile = typeof profiles.$inferSelect; +export type Session = typeof sessions.$inferSelect; \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d637758..621baf3 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,82 +1,97 @@ + import { GitHub, Google } from "arctic"; -import { Lucia } from "lucia"; -import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle"; import { db } from "@/db"; -import { sessions, users } from "@/db/schema"; -import { cookies } from "next/headers"; -import { User } from "lucia"; -import { Session } from "lucia"; +import { + encodeBase32LowerCaseNoPadding, + encodeHexLowerCase, +} from "@oslojs/encoding"; +import { Session, sessions, User, users } from "@/db/schema"; import { env } from "@/env"; -import { UserId as CustomUserId } from "@/types"; - -const adapter = new DrizzleSQLiteAdapter(db, sessions, users); - -export const lucia = new Lucia(adapter, { - sessionCookie: { - expires: false, - attributes: { - secure: process.env.NODE_ENV === "production", - }, - }, - getUserAttributes: (attributes) => { - return { - id: attributes.id, - }; - }, -}); - -export const validateRequest = 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, - }; - } +import { eq } from "drizzle-orm"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { UserId } from "@/types"; +import { getSessionToken } from "../lib/session"; +const SESSION_REFRESH_INTERVAL_MS = 1000 * 60 * 60 * 24 * 15; +const SESSION_MAX_DURATION_MS = SESSION_REFRESH_INTERVAL_MS * 2; - 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: { - id: CustomUserId; - }; - UserId: CustomUserId; - } -} export const github = new GitHub( env.GITHUB_CLIENT_ID, - env.GITHUB_CLIENT_SECRET, + env.GITHUB_CLIENT_SECRET ); export const googleAuth = new Google( env.GOOGLE_CLIENT_ID, env.GOOGLE_CLIENT_SECRET, - `${env.HOST_NAME}/api/login/google/callback`, + `${env.HOST_NAME}/api/login/google/callback` ); +export function generateSessionToken(): string { + const bytes = new Uint8Array(20); + crypto.getRandomValues(bytes); + const token = encodeBase32LowerCaseNoPadding(bytes); + return token; +} +export async function createSession( + token: string, + userId: number +): Promise { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session: Session = { + id: sessionId, + userId, + expiresAt: Date.now() + SESSION_MAX_DURATION_MS, + }; + await db.insert(sessions).values(session); + return session; +} +export async function validateRequest(): Promise { + const sessionToken = getSessionToken(); + if (!sessionToken) { + return { session: null, user: null }; + } + return validateSessionToken(sessionToken); +} +export async function validateSessionToken( + token: string +): Promise { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const sessionInDb = await db.query.sessions.findFirst({ + where: eq(sessions.id, sessionId), + }); + if (!sessionInDb) { + return { session: null, user: null }; + } + if (Date.now() >= new Date(sessionInDb.expiresAt).getTime()) { + await db.delete(sessions).where(eq(sessions.id, sessionInDb.id)); + return { session: null, user: null }; + } + const user = await db.query.users.findFirst({ + where: eq(users.id, sessionInDb.userId), + }); + if (!user) { + await db.delete(sessions).where(eq(sessions.id, sessionInDb.id)); + return { session: null, user: null }; + } + if ( + Date.now() >= + new Date(sessionInDb.expiresAt).getTime() - SESSION_REFRESH_INTERVAL_MS + ) { + sessionInDb.expiresAt = Date.now() + SESSION_MAX_DURATION_MS; + await db + .update(sessions) + .set({ + expiresAt: sessionInDb.expiresAt, + }) + .where(eq(sessions.id, sessionInDb.id)); + } + return { session: sessionInDb, user }; +} +export async function invalidateSession(sessionId: string): Promise { + await db.delete(sessions).where(eq(sessions.id, sessionId)); +} +export async function invalidateUserSessions(userId: UserId): Promise { + await db.delete(sessions).where(eq(users.id, userId)); +} +export type SessionValidationResult = + | { session: Session; user: User } + | { session: null; user: null }; \ No newline at end of file diff --git a/src/lib/session.ts b/src/lib/session.ts index 1e69b5e..3ab6194 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,17 +1,37 @@ import "server-only"; -import { cookies } from "next/headers"; -import { lucia } from "@/lib/auth"; -import { validateRequest } from "@/lib/auth"; -import { cache } from "react"; import { AuthenticationError } from "../use-cases/errors"; +import { createSession, generateSessionToken, validateRequest } from "@/lib/auth"; +import { cache } from "react"; +import { cookies } from "next/headers"; import { UserId } from "@/use-cases/types"; +const SESSION_COOKIE_NAME = "session"; +export function setSessionTokenCookie(token: string, expiresAt: Date): void { + cookies().set(SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + expires: expiresAt, + path: "/", + }); +} + +export function deleteSessionTokenCookie(): void { + cookies().set(SESSION_COOKIE_NAME, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 0, + path: "/", + }); +} +export function getSessionToken(): string | undefined { + return cookies().get(SESSION_COOKIE_NAME)?.value; +} + export const getCurrentUser = cache(async () => { - const session = await validateRequest(); - if (!session.user) { - return undefined; - } - return session.user; + const { user } = await validateRequest(); + return user; }); export const assertAuthenticated = async () => { @@ -23,11 +43,7 @@ export const assertAuthenticated = async () => { }; export async function setSession(userId: UserId) { - const session = await lucia.createSession(userId, {}); - const sessionCookie = lucia.createSessionCookie(session.id); - cookies().set( - sessionCookie.name, - sessionCookie.value, - sessionCookie.attributes, - ); + const token = generateSessionToken(); + const session = await createSession(token, userId); + setSessionTokenCookie(token, new Date(session.expiresAt)); }