diff --git a/Backend/.env.example b/Backend/.env.example index 2d12767..4f1c64b 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -1,6 +1,7 @@ DATABASE_URL=postgres://username:password@localhost:5432/database_name -JWT_SECRET=replace_with_a_long_random_secret -JWT_EXPIRES_IN=7d +BETTER_AUTH_SECRET=replace_with_a_long_random_secret +BETTER_AUTH_URL=http://localhost:3000 +BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:5173 PORT=3000 MINIO_ENDPOINT=localhost MINIO_PORT=9000 diff --git a/Backend/README.md b/Backend/README.md index c27015e..d90ea8e 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -3,7 +3,7 @@ ## Overview Backend for the video upload prototype providing: -- JWT-based authentication +- Better Auth email/password authentication - Presigned MinIO uploads/downloads - An authenticated video administration surface at `/admin` @@ -30,8 +30,9 @@ Required env vars: | Name | Purpose | | --- | --- | | `DATABASE_URL` | Postgres connection string | -| `JWT_SECRET` | Secret used to sign access tokens | -| `JWT_EXPIRES_IN` | Token expiry (e.g., `7d`) | +| `BETTER_AUTH_SECRET` | Secret used to sign sessions | +| `BETTER_AUTH_URL` | Public base URL for the backend (e.g., `http://localhost:3000`) | +| `BETTER_AUTH_TRUSTED_ORIGINS` | Comma-separated list of allowed frontend origins | | `PORT` | HTTP port (default `3000`) | | `MINIO_*` | Connection settings for the MinIO/S3 endpoint | | `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Basic auth for `/admin` dashboard | @@ -54,23 +55,23 @@ bun run dev ```bash bun run db:migrate ``` +- Backfill Better Auth credential accounts for existing users: + ```bash + bun run auth:migrate + ``` - Open Drizzle Studio: ```bash bun run db:studio ``` ## API -All `/videos` and `/admin` routes require a valid JWT Bearer token except for the admin dashboard access, which uses HTTP Basic auth with `ADMIN_USERNAME`/`ADMIN_PASSWORD`. +All `/videos` and `/admin` routes require a valid Better Auth session except for the admin dashboard access, which uses HTTP Basic auth with `ADMIN_USERNAME`/`ADMIN_PASSWORD`. ### Authentication -| Endpoint | Description | -| --- | --- | -| `POST /auth/register` | Create a user (`email`, `password`, `name`) | -| `POST /auth/login` | Receive a token using `email`/`password` | -| `GET /auth/me` | Get the current user ([Authorization](#authorization)) | +Authentication is handled by Better Auth under `/api/auth/*` (for example `/api/auth/sign-in` and `/api/auth/sign-up`). ### Authorization -All authenticated endpoints expect an `Authorization: Bearer ` header containing the JWT issued at login. +All authenticated endpoints expect a Better Auth session cookie sent by the client. ### Video Management | Endpoint | Purpose | @@ -96,4 +97,4 @@ The dashboard UI submits to `/admin/upload-url`, `/admin/objects`, and `/admin/o ## Notes - MinIO bucket creation happens during startup, so the service must be able to reach the endpoint. -- Keep JWT and MinIO secrets out of source control. +- Keep Better Auth and MinIO secrets out of source control. diff --git a/Backend/auth.ts b/Backend/auth.ts new file mode 100644 index 0000000..5b4a01b --- /dev/null +++ b/Backend/auth.ts @@ -0,0 +1,32 @@ +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; + +import { db } from './db/client'; +import { schema } from './db/schema'; +import { hashPassword, verifyPassword } from './utils/password'; + +const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS + ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean) + : undefined; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: 'pg', + schema: { + ...schema, + user: schema.users, + }, + }), + emailAndPassword: { + enabled: true, + password: { + hash: async (password) => hashPassword(password), + verify: async ({ hash, password }) => verifyPassword(password, hash), + }, + }, + secret: process.env.BETTER_AUTH_SECRET, + baseURL: process.env.BETTER_AUTH_URL, + trustedOrigins, +}); + +export type AuthSession = Awaited>; diff --git a/Backend/db/client.ts b/Backend/db/client.ts index 7f942e8..ea5741d 100644 --- a/Backend/db/client.ts +++ b/Backend/db/client.ts @@ -1,4 +1,3 @@ -import 'dotenv/config'; import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; diff --git a/Backend/db/schema.ts b/Backend/db/schema.ts index 0e621b8..801a783 100644 --- a/Backend/db/schema.ts +++ b/Backend/db/schema.ts @@ -5,7 +5,10 @@ export const users = pgTable('users', { email: varchar('email', { length: 255 }).notNull().unique(), name: varchar('name', { length: 255 }).notNull(), passwordHash: varchar('password_hash', { length: 255 }).notNull(), + emailVerified: boolean('email_verified').default(false).notNull(), + image: text('image'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }); export const devices = pgTable('devices', { @@ -56,3 +59,50 @@ export const notifications = pgTable('notifications', { status: varchar('status', { length: 32 }).default('queued').notNull(), isRead: boolean('is_read').default(false).notNull(), }); + +export const accounts = pgTable('account', { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id').notNull().references(() => users.id), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), + idToken: text('id_token'), + scope: text('scope'), + password: text('password'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const sessions = pgTable('session', { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id').notNull().references(() => users.id), + token: text('token').notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const verifications = pgTable('verification', { + id: uuid('id').defaultRandom().primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const schema = { + users, + devices, + events, + videos, + notifications, + accounts, + sessions, + verifications, +}; diff --git a/Backend/index.ts b/Backend/index.ts index 400daaa..758b75f 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -1,20 +1,20 @@ -import 'dotenv/config'; import express from 'express'; +import { toNodeHandler } from 'better-auth/node'; -import authRoutes from './routes/auth'; +import { auth } from './auth'; import videosRoutes from './routes/videos'; import adminRoutes from './routes/admin'; import { ensureMinioBucket } from './utils/minio'; const app = express(); -app.use(express.json()); - app.get('/', (_req, res) => { res.send('API is running'); }); -app.use('/auth', authRoutes); +app.all('/api/auth/*splat', toNodeHandler(auth)); + +app.use(express.json()); app.use('/videos', videosRoutes); app.use('/admin', adminRoutes); diff --git a/Backend/middleware/auth.ts b/Backend/middleware/auth.ts index 082393c..bfe3e45 100644 --- a/Backend/middleware/auth.ts +++ b/Backend/middleware/auth.ts @@ -1,22 +1,24 @@ import type { NextFunction, Request, Response } from 'express'; -import { verifyAccessToken } from '../utils/jwt'; +import { fromNodeHeaders } from 'better-auth/node'; -export function requireAuth(req: Request, res: Response, next: NextFunction): void { - const authorization = req.headers.authorization; - - if (!authorization?.startsWith('Bearer ')) { - res.status(401).json({ message: 'Missing or invalid authorization header' }); - return; - } - - const token = authorization.slice(7); +import { auth } from '../auth'; +export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise { try { - const payload = verifyAccessToken(token); - req.user = payload; + const session = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + + if (!session) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + req.auth = session; next(); - } catch { - res.status(401).json({ message: 'Invalid or expired token' }); + } catch (error) { + console.error('Auth session lookup failed', error); + res.status(401).json({ message: 'Unauthorized' }); } } diff --git a/Backend/package.json b/Backend/package.json index 2cbf40b..56f78c3 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -8,7 +8,6 @@ "@types/bun": "latest", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.2.1", "@types/pg": "^8.16.0", "drizzle-kit": "^0.31.0", @@ -26,7 +25,7 @@ "drizzle-orm": "^0.44.0", "express": "^5.2.1", "helmet": "^8.1.0", - "jsonwebtoken": "^9.0.3", + "better-auth": "^1.4.0", "minio": "^8.0.6", "openai": "^6.18.0", "pg": "^8.18.0", @@ -40,6 +39,7 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "auth:migrate": "bun run scripts/migrate-better-auth.ts" } } diff --git a/Backend/routes/auth.ts b/Backend/routes/auth.ts deleted file mode 100644 index 32d800b..0000000 --- a/Backend/routes/auth.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Router } from 'express'; -import { and, eq } from 'drizzle-orm'; -import { z } from 'zod'; - -import { db } from '../db/client'; -import { users } from '../db/schema'; -import { requireAuth } from '../middleware/auth'; -import { signAccessToken } from '../utils/jwt'; -import { hashPassword, verifyPassword } from '../utils/password'; - -const router = Router(); - -const registerSchema = z.object({ - email: z.email().trim().toLowerCase(), - password: z.string().min(8), - name: z.string().trim().min(1).max(255), -}); - -const loginSchema = z.object({ - email: z.email().trim().toLowerCase(), - password: z.string().min(1), -}); - -router.post('/register', async (req, res) => { - const parsed = registerSchema.safeParse(req.body); - - if (!parsed.success) { - res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); - return; - } - - const { email, password, name } = parsed.data; - - const existingUser = await db.query.users.findFirst({ - where: eq(users.email, email), - }); - - if (existingUser) { - res.status(409).json({ message: 'Email already in use' }); - return; - } - - const passwordHash = await hashPassword(password); - - const [newUser] = await db - .insert(users) - .values({ - email, - name, - passwordHash, - }) - .returning({ - id: users.id, - email: users.email, - name: users.name, - createdAt: users.createdAt, - }); - - if (!newUser) { - res.status(500).json({ message: 'Failed to create user' }); - return; - } - - const token = signAccessToken({ userId: newUser.id, email: newUser.email }); - - res.status(201).json({ token, user: newUser }); -}); - -router.post('/login', async (req, res) => { - const parsed = loginSchema.safeParse(req.body); - - if (!parsed.success) { - res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); - return; - } - - const { email, password } = parsed.data; - - const user = await db.query.users.findFirst({ - where: eq(users.email, email), - }); - - if (!user) { - res.status(401).json({ message: 'Invalid email or password' }); - return; - } - - const isPasswordValid = await verifyPassword(password, user.passwordHash); - - if (!isPasswordValid) { - res.status(401).json({ message: 'Invalid email or password' }); - return; - } - - const token = signAccessToken({ userId: user.id, email: user.email }); - - res.json({ - token, - user: { - id: user.id, - email: user.email, - name: user.name, - createdAt: user.createdAt, - }, - }); -}); - -router.get('/me', requireAuth, async (req, res) => { - const authenticatedUser = req.user; - - if (!authenticatedUser) { - res.status(401).json({ message: 'Unauthorized' }); - return; - } - - const user = await db.query.users.findFirst({ - where: and(eq(users.id, authenticatedUser.userId), eq(users.email, authenticatedUser.email)), - columns: { - id: true, - email: true, - name: true, - createdAt: true, - }, - }); - - if (!user) { - res.status(404).json({ message: 'User not found' }); - return; - } - - res.json({ user }); -}); - -export default router; diff --git a/Backend/routes/videos.ts b/Backend/routes/videos.ts index b47c7e5..f1acdf4 100644 --- a/Backend/routes/videos.ts +++ b/Backend/routes/videos.ts @@ -46,16 +46,16 @@ router.post('/upload-url', async (req, res) => { return; } - const user = req.user; + const authSession = req.auth; - if (!user) { + if (!authSession?.user) { res.status(401).json({ message: 'Unauthorized' }); return; } await ensureMinioBucket(); - const objectKey = buildObjectKey(user.userId, parsed.data.fileName, parsed.data.prefix); + const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix); const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); const now = new Date(); const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000); @@ -63,7 +63,7 @@ router.post('/upload-url', async (req, res) => { const [videoRecord] = await db .insert(videos) .values({ - userId: user.userId, + userId: authSession.user.id, objectKey, bucket: minioBucket, uploadUrl, diff --git a/Backend/scripts/migrate-better-auth.ts b/Backend/scripts/migrate-better-auth.ts new file mode 100644 index 0000000..dfc27ff --- /dev/null +++ b/Backend/scripts/migrate-better-auth.ts @@ -0,0 +1,47 @@ +import { and, eq } from 'drizzle-orm'; + +import { db } from '../db/client'; +import { accounts, users } from '../db/schema'; + +const PROVIDER_ID = 'credential'; + +const run = async (): Promise => { + const existingUsers = await db.select({ + id: users.id, + passwordHash: users.passwordHash, + }).from(users); + + let created = 0; + + for (const user of existingUsers) { + const account = await db.query.accounts.findFirst({ + where: and( + eq(accounts.userId, user.id), + eq(accounts.providerId, PROVIDER_ID), + eq(accounts.accountId, user.id), + ), + }); + + if (account) { + continue; + } + + await db.insert(accounts).values({ + userId: user.id, + accountId: user.id, + providerId: PROVIDER_ID, + password: user.passwordHash, + }); + + created += 1; + } + + console.log(`Created ${created} credential account(s).`); +}; + +run() + .then(() => process.exit(0)) + .catch((error) => { + console.error('Failed to migrate Better Auth accounts', error); + process.exit(1); + }); diff --git a/Backend/types/express.d.ts b/Backend/types/express.d.ts index 04a8cd5..6636ed3 100644 --- a/Backend/types/express.d.ts +++ b/Backend/types/express.d.ts @@ -1,9 +1,17 @@ -import type { JwtPayload } from '../utils/jwt'; - declare global { namespace Express { interface Request { - user?: JwtPayload; + auth?: { + user: { + id: string; + email: string; + name?: string | null; + }; + session: { + id: string; + userId: string; + }; + }; } } } diff --git a/Backend/utils/jwt.ts b/Backend/utils/jwt.ts deleted file mode 100644 index 60fea45..0000000 --- a/Backend/utils/jwt.ts +++ /dev/null @@ -1,28 +0,0 @@ -import jwt from 'jsonwebtoken'; - -const JWT_EXPIRES_IN = (process.env.JWT_EXPIRES_IN ?? '7d') as jwt.SignOptions['expiresIn']; - -function getJwtSecret(): string { - const secret = process.env.JWT_SECRET; - - if (!secret) { - throw new Error('JWT_SECRET is not set'); - } - - return secret; -} - -export type JwtPayload = { - userId: string; - email: string; -}; - -export function signAccessToken(payload: JwtPayload): string { - return jwt.sign(payload, getJwtSecret(), { - expiresIn: JWT_EXPIRES_IN, - }); -} - -export function verifyAccessToken(token: string): JwtPayload { - return jwt.verify(token, getJwtSecret()) as JwtPayload; -}