From 413c47aa872d91edf69bbc37df5f890a3bcf6365 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Fri, 28 Aug 2026 16:49:27 +0200 Subject: [PATCH] feat(auth): integrate auth.js v5 with credentials provider and bcrypt hashing --- README.md | 7 +- app/api/auth/[...nextauth]/route.ts | 11 +++ auth.ts | 96 ++++++++++++++++++++++ lib/password.ts | 30 +++++++ lib/types/next-auth.d.ts | 35 ++++++++ middleware.ts | 34 ++++++++ package-lock.json | 121 ++++++++++++++++++++++++++++ package.json | 3 + 8 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 auth.ts create mode 100644 lib/password.ts create mode 100644 lib/types/next-auth.d.ts create mode 100644 middleware.ts diff --git a/README.md b/README.md index d138975..38eea22 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A modern, high-performance real-time chat application, designed for seamless com ![React](https://img.shields.io/badge/React-19-blue?style=flat-square&logo=react) ![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?style=flat-square&logo=typescript) ![Tailwind CSS](https://img.shields.io/badge/Tailwind-v4-38bdf8?style=flat-square&logo=tailwind-css) +![Auth.js](https://img.shields.io/badge/Auth.js-v5-purple?style=flat-square) ![Drizzle ORM](https://img.shields.io/badge/Drizzle-ORM-C5F74F?style=flat-square&logo=drizzle) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql) @@ -14,7 +15,7 @@ A modern, high-performance real-time chat application, designed for seamless com - **Framework:** [Next.js 16](https://nextjs.org/) (App Router, Route Groups) - **UI & Styling:** [React 19](https://react.dev/), [Tailwind CSS v4](https://tailwindcss.com/) - **Database & ORM:** [PostgreSQL](https://www.postgresql.org/) managed via [Drizzle ORM](https://orm.drizzle.team/) & Drizzle Kit (Studio included) -- **Authentication:** [Auth.js v5 (NextAuth)](https://authjs.dev/) with Credentials Provider & bcrypt hashing _(planned)_ +- **Authentication:** [Auth.js v5 (NextAuth)](https://authjs.dev/) with Credentials Provider & bcrypt hashing - **DevOps & Infrastructure:** Docker & Docker Compose, Caddy Reverse Proxy, Forgejo Actions _(planned)_ ## 📂 Architecture & Structure @@ -22,12 +23,14 @@ A modern, high-performance real-time chat application, designed for seamless com The project uses Next.js Route Groups without a `src/` directory to maintain a clean root layout: - `app/(app)/` — Application routes (Layout, Home, Components) +- `app/api/` — Backend API endpoints & Auth handlers (`[...nextauth]`) - `components/` — Modular UI components (Chat, Navigation, Sidebars) - `db/` — Database schema definitions, migrations, and Drizzle configuration (`drizzle.config.ts`) - `lib/` — Centralized core logic folder containing: - `store/` — State management stores + - `types/` — Global TypeScript interfaces and type definitions - `public/` — Static assets (images, icons, fonts) ## 🎯 Current Status -_In Progress_ — Application scaffold initialized with Next.js 16, React 19, Tailwind CSS v4, and TypeScript. Database layer fully set up with PostgreSQL and Drizzle ORM schemas. Authentication and DevOps infrastructure will be implemented in subsequent phases. +_In Progress_ — Application scaffold initialized with Next.js 16, React 19, Tailwind CSS v4, and TypeScript. Database layer fully set up with PostgreSQL and Drizzle ORM schemas, full authentication (Auth.js v5). DevOps infrastructure will be implemented in subsequent phases. diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..222fc5a --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,11 @@ +/** + * @file app/api/auth/[...nextauth]/route.ts + * @description NextAuth API route handlers for HTTP GET and POST authentication requests. + */ + +import { handlers } from "@/auth"; + +/** + * HTTP GET and POST request handlers exported from NextAuth configuration. + */ +export const { GET, POST } = handlers; diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..6dec7fe --- /dev/null +++ b/auth.ts @@ -0,0 +1,96 @@ +/** + * @file auth.ts + * @description NextAuth configuration defining authentication providers, credentials verification, JWT callbacks, and session handling. + */ + +import NextAuth from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { verifyPassword } from "@/lib/password"; + +/** + * NextAuth handlers, authentication methods, and auth utility exports. + */ +export const { handlers, signIn, signOut, auth } = NextAuth({ + providers: [ + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + /** + * Authorizes user credentials by checking against database records and verifying the password. + * + * @param {Record | undefined} credentials - The incoming sign-in credentials containing email and password. + * @returns {Promise} The authenticated user object containing id, name, email, and color, or null if validation fails. + */ + authorize: async (credentials) => { + const email = credentials?.email as string | undefined; + const password = credentials?.password as string | undefined; + + if (!email || !password) { + return null; + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (!user || !user.password) { + return null; + } + + const isValid = await verifyPassword(password, user.password); + if (!isValid) { + return null; + } + + return { + id: user.id, + name: user.username, + email: user.email, + color: user.color, + }; + }, + }), + ], + callbacks: { + /** + * Callback triggered when a JSON Web Token is created or updated. + * + * @param {Object} params - Callback parameters. + * @param {Object} params.token - The current JWT token payload. + * @param {Object} [params.user] - The authenticated user object available on initial sign in. + * @returns {Object} The updated JWT token containing custom user claims. + */ + jwt({ token, user }) { + if (user) { + token.id = user.id; + token.color = user.color; + } + return token; + }, + /** + * Callback triggered whenever a session is checked or accessed. + * + * @param {Object} params - Callback parameters. + * @param {Object} params.session - The current session object. + * @param {Object} params.token - The decoded JWT token payload. + * @returns {Object} The updated session object populated with custom token attributes. + */ + session({ session, token }) { + if (token && session.user) { + session.user.id = token.id as string; + session.user.color = token.color as string; + } + return session; + }, + }, + pages: { + signIn: "/login", + }, +}); diff --git a/lib/password.ts b/lib/password.ts new file mode 100644 index 0000000..67072c8 --- /dev/null +++ b/lib/password.ts @@ -0,0 +1,30 @@ +/** + * @file lib/password.ts + * @description Utility functions for securely hashing and verifying passwords using bcryptjs. + */ + +import bcrypt from "bcryptjs"; + +/** + * Hashes a plain text password using bcrypt with a salt round factor of 10. + * + * @param {string} password - The plain text password to be hashed. + * @returns {Promise} A promise that resolves to the generated password hash string. + */ +export async function hashPassword(password: string): Promise { + return await bcrypt.hash(password, 10); +} + +/** + * Compares a plain text password against a stored bcrypt hash to verify its validity. + * + * @param {string} password - The plain text password to verify. + * @param {string} hash - The bcrypt hash to compare against. + * @returns {Promise} A promise that resolves to true if the password matches the hash, or false otherwise. + */ +export async function verifyPassword( + password: string, + hash: string, +): Promise { + return await bcrypt.compare(password, hash); +} diff --git a/lib/types/next-auth.d.ts b/lib/types/next-auth.d.ts new file mode 100644 index 0000000..0422129 --- /dev/null +++ b/lib/types/next-auth.d.ts @@ -0,0 +1,35 @@ +/** + * @file lib/types/next-auth.d.ts + * @description TypeScript module augmentation for NextAuth types to add custom user and session attributes. + */ + +import { DefaultSession } from "next-auth"; + +declare module "next-auth" { + /** + * Extends the default NextAuth Session interface to include custom user properties. + * + * @interface Session + * @property {Object} user - The session user object. + * @property {string} user.id - The unique identifier of the user. + * @property {string} user.color - The custom user interface color preference or avatar fallback color. + */ + interface Session { + user: { + id: string; + color: string; + } & DefaultSession["user"]; + } + + /** + * Extends the default NextAuth User interface to include custom properties. + * + * @interface User + * @property {string} [id] - The optional unique identifier of the user. + * @property {string} [color] - The optional custom color attribute associated with the user. + */ + interface User { + id?: string; + color?: string; + } +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..6e32aea --- /dev/null +++ b/middleware.ts @@ -0,0 +1,34 @@ +/** + * @file middleware.ts + * @description NextAuth middleware protecting routes, managing authentication state, and redirecting unauthenticated or authenticated users accordingly. + */ + +import { auth } from "@/auth"; + +/** + * Middleware function that checks authentication status on incoming requests and handles route redirection. + * + * @param {Request & { auth?: unknown, nextUrl: URL }} req - The incoming request object extended with NextAuth authentication details and URL helper. + * @returns {Response | undefined} Redirection response if authentication criteria are not met, or undefined to allow request processing. + */ +export default auth((req) => { + const isLoggedIn = !!req.auth; + const isAuthPage = + req.nextUrl.pathname.startsWith("/login") || + req.nextUrl.pathname.startsWith("/register"); + + if (!isLoggedIn && !isAuthPage) { + return Response.redirect(new URL("/login", req.nextUrl)); + } + + if (isLoggedIn && isAuthPage) { + return Response.redirect(new URL("/", req.nextUrl)); + } +}); + +/** + * Middleware execution matcher configuration. + */ +export const config = { + matcher: ["/((?!api|_next/static|_next/image|favicon.ico|logo.png).*)"], +}; diff --git a/package-lock.json b/package-lock.json index 269a83f..650f7d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "waveform", "version": "0.1.0", "dependencies": { + "bcryptjs": "^3.0.3", "clsx": "^2.1.1", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "lucide-react": "^1.35.0", "next": "16.3.3", + "next-auth": "^5.0.0-beta.32", "postgres": "^3.4.9", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -20,6 +22,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.3.3", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.23.1", "@types/react": "^19", @@ -44,6 +47,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7 || ^8.0.5" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2270,6 +2302,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2580,6 +2621,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3597,6 +3645,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -5831,6 +5888,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6464,6 +6530,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7 || ^8.0.5", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", @@ -6521,6 +6614,15 @@ "node": ">=18" } }, + "node_modules/oauth4webapi": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", + "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6901,6 +7003,25 @@ "node": ">=0.10.0" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 689d7a1..2d285df 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "bcryptjs": "^3.0.3", "clsx": "^2.1.1", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "lucide-react": "^1.35.0", "next": "16.3.3", + "next-auth": "^5.0.0-beta.32", "postgres": "^3.4.9", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -25,6 +27,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.3.3", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.23.1", "@types/react": "^19",