This commit is contained in:
Garret Patti
2026-04-05 17:44:24 -04:00
parent f0666c0649
commit eecee9bc5f
41 changed files with 1405 additions and 28 deletions

112
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,112 @@
import { getIronSession, type IronSession, type SessionOptions } from 'iron-session'
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import crypto from 'crypto'
export interface SessionData {
userId: string
username: string
role: 'admin' | 'user'
}
export function getSessionOptions(): SessionOptions {
const secret = process.env.SESSION_SECRET
if (!secret) throw new Error('SESSION_SECRET is not set')
return {
password: secret,
cookieName: 'ml_session',
ttl: 60 * 60 * 24 * 30, // 30 days
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax',
},
}
}
// For use in server components and layouts (Node.js runtime, read-only)
export async function getServerSession(): Promise<IronSession<SessionData>> {
const cookieStore = await cookies()
return getIronSession<SessionData>(cookieStore, getSessionOptions())
}
// For use in API routes where the session must be written (login/logout)
// The caller is responsible for returning the response so Set-Cookie headers are sent
export async function getWritableSession(
req: NextRequest,
res: NextResponse
): Promise<IronSession<SessionData>> {
return getIronSession<SessionData>(req, res, getSessionOptions())
}
// Password hashing using Node.js built-in crypto (Node.js runtime only)
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16)
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
crypto.scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => {
if (err) reject(err)
else resolve(key)
})
})
return `scrypt:${salt.toString('hex')}:${derivedKey.toString('hex')}`
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
const parts = hash.split(':')
if (parts.length !== 3 || parts[0] !== 'scrypt') return false
const salt = Buffer.from(parts[1], 'hex')
const storedKey = Buffer.from(parts[2], 'hex')
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
crypto.scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => {
if (err) reject(err)
else resolve(key)
})
})
if (derivedKey.length !== storedKey.length) return false
return crypto.timingSafeEqual(derivedKey, storedKey)
}
// Auth guard result type
type AuthSuccess = { session: IronSession<SessionData> }
type AuthResult = AuthSuccess | NextResponse
// Read-only session from an API route request (throwaway response)
async function getReadonlySession(req: NextRequest): Promise<IronSession<SessionData>> {
const res = new NextResponse()
return getIronSession<SessionData>(req, res, getSessionOptions())
}
export async function requireAuth(req: NextRequest): Promise<AuthResult> {
const session = await getReadonlySession(req)
if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return { session }
}
export async function requireAdmin(req: NextRequest): Promise<AuthResult> {
const session = await getReadonlySession(req)
if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return { session }
}
export async function requireLibraryAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
const session = await getReadonlySession(req)
if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (session.role === 'admin') return { session }
// Lazy import to avoid pulling DB into edge contexts
const { getPermittedLibraryIds } = await import('./users')
const permitted = getPermittedLibraryIds(session.userId)
if (!permitted.includes(libraryId)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return { session }
}