feat: migrate to Better Auth for authentication, update environment variables, and enhance database schema with accounts and sessions

This commit is contained in:
2025-12-20 11:00:00 +00:00
parent 377836d1fa
commit 7bff6b0f91
13 changed files with 183 additions and 205 deletions

View File

@@ -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

View File

@@ -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 <token>` 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.

32
Backend/auth.ts Normal file
View File

@@ -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<ReturnType<typeof auth.api.getSession>>;

View File

@@ -1,4 +1,3 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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;
import { auth } from '../auth';
if (!authorization?.startsWith('Bearer ')) {
res.status(401).json({ message: 'Missing or invalid authorization header' });
export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (!session) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const token = authorization.slice(7);
try {
const payload = verifyAccessToken(token);
req.user = payload;
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' });
}
}

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<void> => {
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);
});

View File

@@ -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;
};
};
}
}
}

View File

@@ -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;
}