From a662ed8d6abc82cf9331e31e4cfb0aec8bd2d797 Mon Sep 17 00:00:00 2001 From: Toan Hoang Minh Date: Fri, 31 Jul 2026 11:55:58 +0700 Subject: [PATCH] feat: add Auth.js Credentials login config + route --- .env.example | 6 ++ package-lock.json | 94 +++++++++++++++++++++++++ package.json | 1 + src/app/api/auth/[...nextauth]/route.ts | 3 + src/auth.ts | 48 +++++++++++++ src/types/next-auth.d.ts | 14 ++++ 6 files changed, 166 insertions(+) create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/auth.ts create mode 100644 src/types/next-auth.d.ts diff --git a/.env.example b/.env.example index e76011e..61cf2f6 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,9 @@ # see CLAUDE.md's "Môi trường" section for the exact docker compose/run # commands used to run the app and tests on this Windows host. DATABASE_URL=postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt + +# Auth.js v5 (next-auth@beta) — required. Random value used to sign/encrypt +# session JWTs. Generate one with `npx auth secret` (writes straight to +# .env/.env.local) or `openssl rand -base64 33`, then copy the value here. +# Do NOT reuse this placeholder — must be a real per-environment secret. +AUTH_SECRET=change-me-generate-a-real-secret diff --git a/package-lock.json b/package-lock.json index 69a829c..009d08c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "clsx": "^2.1.1", "lucide-react": "^1.28.0", "next": "14.2.35", + "next-auth": "^5.0.0-beta.32", "radix-ui": "^1.6.7", "react": "18.3.1", "react-dom": "18.3.1", @@ -53,6 +54,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", @@ -1707,6 +1737,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/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -9256,6 +9295,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.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -9350,6 +9416,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "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", @@ -10148,6 +10223,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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 e416e8f..ae973de 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "clsx": "^2.1.1", "lucide-react": "^1.28.0", "next": "14.2.35", + "next-auth": "^5.0.0-beta.32", "radix-ui": "^1.6.7", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c9f3d --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..0c08f2e --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,48 @@ +import NextAuth from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import { verifyUserCredentials } from "@/lib/users"; + +// Minimal internal auth — Auth.js v5 (next-auth@beta) Credentials provider +// only, no OAuth/SSO providers (company SSO was explicitly ruled out — see +// prisma/schema.prisma's User model comment / CLAUDE.md). JWT session +// strategy, not database sessions, so no adapter is configured here — the +// Credentials provider's authorize() talks to Postgres directly via +// verifyUserCredentials() (src/lib/users.ts), and the User table has no +// Account/Session/VerificationToken rows to manage. +// +// Permission model is flat: any logged-in user can do anything, so the +// session only needs to carry the user's id (used to identify who did what, +// not to gate what they can do) — see the module augmentation in +// src/types/next-auth.d.ts. +export const { handlers, signIn, signOut, auth } = NextAuth({ + session: { strategy: "jwt" }, + pages: { + signIn: "/login", + }, + providers: [ + Credentials({ + credentials: { + email: {}, + password: {}, + }, + authorize: async (credentials) => { + const user = await verifyUserCredentials(credentials?.email, credentials?.password); + return user; + }, + }), + ], + callbacks: { + jwt({ token, user }) { + if (user) { + token.id = user.id; + } + return token; + }, + session({ session, token }) { + if (session.user) { + session.user.id = token.id as string; + } + return session; + }, + }, +}); diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..129e4e7 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,14 @@ +import type { DefaultSession } from "next-auth"; + +// Auth.js v5's default Session["user"] shape (name/email/image) doesn't +// include `id`. src/auth.ts's jwt/session callbacks copy the user id from +// the Credentials provider's authorize() result through to the session, so +// callers can identify who's logged in — this augmentation makes that +// typed instead of requiring a cast everywhere it's read. +declare module "next-auth" { + interface Session { + user: { + id: string; + } & DefaultSession["user"]; + } +}