From 5eb195cbeb917405f3c9e57f6b7312bbc2d86bf2 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 15:53:48 +0000 Subject: [PATCH] feat: add session auth routes --- apps/api/src/index.ts | 6 +- apps/api/src/lib/auth.ts | 113 +++++++++++++++++++++++++++++ apps/api/src/routes/auth.ts | 141 ++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/lib/auth.ts create mode 100644 apps/api/src/routes/auth.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d903997..3a14c38 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,14 +1,18 @@ import { Elysia } from 'elysia'; -import { env } from './config/env'; +import { env, assertRequiredEnv } from './config/env'; import { healthRoutes } from './routes/health'; import { statusRoutes } from './routes/status'; import { diseaseRoutes } from './routes/diseases'; import { classificationRoutes } from './routes/classifications'; import { dashboardRoutes } from './routes/dashboard'; +import { authRoutes } from './routes/auth'; + +assertRequiredEnv(); const app = new Elysia() .use(healthRoutes) .use(statusRoutes) + .use(authRoutes) .use(diseaseRoutes) .use(classificationRoutes) .use(dashboardRoutes) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts new file mode 100644 index 0000000..543df19 --- /dev/null +++ b/apps/api/src/lib/auth.ts @@ -0,0 +1,113 @@ +import { createHash, randomBytes } from 'crypto'; +import bcrypt from 'bcryptjs'; +import { and, eq, gt } from 'drizzle-orm'; +import type { AuthUser } from '@zeavis/shared'; +import { createDbClient } from '../db/client'; +import { sessions, users } from '../db/schema'; +import { env } from '../config/env'; + +export const sessionCookieName = 'zeavis_session'; + +export type CurrentUser = AuthUser; + +export function getAuthFeatures() { + return { + googleOAuthEnabled: env.googleOAuthEnabled, + }; +} + +export async function hashPassword(password: string) { + return bcrypt.hash(password, 12); +} + +export async function verifyPassword(password: string, passwordHash: string) { + return bcrypt.compare(password, passwordHash); +} + +function hashToken(token: string) { + return createHash('sha256').update(`${env.sessionSecret}:${token}`).digest('hex'); +} + +export function createSessionCookie(token: string) { + const maxAge = 60 * 60 * 24 * 30; + return `${sessionCookieName}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${maxAge}`; +} + +export function clearSessionCookie() { + return `${sessionCookieName}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`; +} + +export function readSessionToken(cookieHeader: string | null | undefined) { + if (!cookieHeader) return null; + + const cookies = cookieHeader.split(';').map((part) => part.trim()); + const sessionCookie = cookies.find((cookie) => cookie.startsWith(`${sessionCookieName}=`)); + if (!sessionCookie) return null; + + return decodeURIComponent(sessionCookie.slice(sessionCookieName.length + 1)); +} + +export async function createSession(userId: string) { + const db = createDbClient(); + const token = randomBytes(32).toString('base64url'); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + + await db.insert(sessions).values({ + userId, + tokenHash, + expiresAt, + }); + + return token; +} + +export async function deleteSession(token: string | null) { + if (!token) return; + + const db = createDbClient(); + await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token))); +} + +export async function getCurrentUser(cookieHeader: string | null | undefined): Promise { + const token = readSessionToken(cookieHeader); + if (!token) return null; + + const db = createDbClient(); + const rows = await db + .select({ + id: users.id, + email: users.email, + name: users.name, + role: users.role, + }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date()))) + .limit(1); + + const user = rows[0]; + if (!user) return null; + + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role === 'expert' ? 'expert' : 'user', + }; +} + +export function requireUser(user: CurrentUser | null): CurrentUser { + if (!user) { + throw new Error('UNAUTHORIZED'); + } + return user; +} + +export function requireExpert(user: CurrentUser | null): CurrentUser { + const currentUser = requireUser(user); + if (currentUser.role !== 'expert') { + throw new Error('FORBIDDEN'); + } + return currentUser; +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..2d4fc81 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,141 @@ +import { Elysia } from 'elysia'; +import type { AuthRequest, RegisterRequest } from '@zeavis/shared'; +import { eq } from 'drizzle-orm'; +import { createDbClient } from '../db/client'; +import { users } from '../db/schema'; +import { badRequest, serviceUnavailable } from '../lib/http-errors'; +import { + clearSessionCookie, + createSession, + createSessionCookie, + deleteSession, + getAuthFeatures, + getCurrentUser, + hashPassword, + readSessionToken, + verifyPassword, +} from '../lib/auth'; +import { env } from '../config/env'; + +function normalizeEmail(email: unknown) { + return typeof email === 'string' ? email.trim().toLowerCase() : ''; +} + +function normalizeName(name: unknown) { + return typeof name === 'string' ? name.trim() : ''; +} + +function validatePassword(password: unknown) { + return typeof password === 'string' && password.length >= 8; +} + +export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) + .get('/me', async ({ request }) => { + const user = await getCurrentUser(request.headers.get('cookie')); + return { + user, + features: getAuthFeatures(), + }; + }) + .post('/register', async ({ body, set }) => { + const req = body as Partial | undefined; + const email = normalizeEmail(req?.email); + const name = normalizeName(req?.name); + + if (!email || !email.includes('@') || !name || !validatePassword(req?.password)) { + return badRequest('Name, valid email, and password with at least 8 characters are required'); + } + + try { + const db = createDbClient(); + const existing = await db.select().from(users).where(eq(users.email, email)).limit(1); + if (existing.length > 0) { + return badRequest('Email is already registered'); + } + + const passwordHash = await hashPassword(req!.password!); + const inserted = await db + .insert(users) + .values({ email, name, passwordHash, role: 'user' }) + .returning(); + + const user = inserted[0]; + const token = await createSession(user.id); + set.headers['Set-Cookie'] = createSessionCookie(token); + + return { + user: { + id: user.id, + email: user.email, + name: user.name, + role: 'user' as const, + }, + features: getAuthFeatures(), + }; + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + }) + .post('/login', async ({ body, set }) => { + const req = body as Partial | undefined; + const email = normalizeEmail(req?.email); + + if (!email || !validatePassword(req?.password)) { + return badRequest('Valid email and password are required'); + } + + try { + const db = createDbClient(); + const rows = await db.select().from(users).where(eq(users.email, email)).limit(1); + const user = rows[0]; + + if (!user?.passwordHash || !(await verifyPassword(req!.password!, user.passwordHash))) { + return badRequest('Invalid email or password'); + } + + const token = await createSession(user.id); + set.headers['Set-Cookie'] = createSessionCookie(token); + + return { + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role === 'expert' ? 'expert' as const : 'user' as const, + }, + features: getAuthFeatures(), + }; + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + }) + .post('/logout', async ({ request, set }) => { + await deleteSession(readSessionToken(request.headers.get('cookie'))); + set.headers['Set-Cookie'] = clearSessionCookie(); + return { ok: true }; + }) + .get('/google', ({ set }) => { + if (!env.googleOAuthEnabled) { + set.status = 404; + return { error: 'Google OAuth is not configured' }; + } + + const params = new URLSearchParams({ + client_id: env.googleClientId!, + redirect_uri: env.googleRedirectUri!, + response_type: 'code', + scope: 'openid email profile', + access_type: 'offline', + prompt: 'select_account', + }); + + set.redirect = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; + }) + .get('/google/callback', ({ set }) => { + if (!env.googleOAuthEnabled) { + set.status = 404; + return { error: 'Google OAuth is not configured' }; + } + + set.redirect = `${env.webAppUrl}/login?oauth=not-implemented`; + });