import { Hono } from 'hono' import { randomInt } from 'crypto' import { z } from 'zod' import { config } from '../config' import { sendEmailVerificationCode } from '../mail' import { verifyPassword } from '../security/password' import { signAccessToken } from '../security/tokens' import type { AuthUser } from '../types' import { authRequired, type AuthVariables } from './middleware' import { clearLoginAttempts, assignUserAffiliation, changeUserPassword, createManagedUser, createOrganization, createHandoffToken, createRefreshToken, createUser, deleteUserProfileImage, exchangeHandoffToken, findUserProfileImage, findUserByEmail, listOrganizations, isLoginBlocked, markLastLogin, recordFailedLogin, revokeRefreshToken, rotateRefreshToken, storeEmailVerificationCode, storePasswordResetCode, updateUserPassword, updateUserProfileImage, verifyEmailVerificationCode, verifyPasswordResetCode } from './repository' const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1) }) const signupSchema = z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().trim().min(1).max(80), verificationCode: z.string().regex(/^\d{6}$/) }) const emailVerificationSendSchema = z.object({ email: z.string().email() }) const passwordResetSendSchema = z.object({ email: z.string().email() }) const passwordResetConfirmSchema = z.object({ email: z.string().email(), verificationCode: z.string().regex(/^\d{6}$/), password: z.string().min(8) }) const passwordChangeSchema = z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(8) }).refine((value) => value.currentPassword !== value.newPassword, { path: ['newPassword'], message: 'new_password_must_be_different' }) const organizationTypeSchema = z.enum(['COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER']) const createOrganizationSchema = z.object({ name: z.string().trim().min(1).max(120), type: organizationTypeSchema.default('OTHER'), domain: z.string().trim().max(120).optional().nullable() }) const assignAffiliationSchema = z.object({ organizationId: z.string().uuid(), department: z.string().trim().max(120).optional().nullable(), position: z.string().trim().max(120).optional().nullable(), isPrimary: z.boolean().optional() }) const adminCreateUserSchema = z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().trim().min(1).max(80), organizationId: z.string().uuid(), department: z.string().trim().max(120).optional().nullable(), position: z.string().trim().max(120).optional().nullable(), licenseCode: z.enum(['FREE', 'EDU', 'PRO']).optional() }) const refreshSchema = z.object({ refreshToken: z.string().min(1).optional() }) const logoutSchema = z.object({ refreshToken: z.string().min(1).optional() }) const handoffSchema = z.object({ audienceApp: z.string().min(1).max(120) }) const handoffExchangeSchema = z.object({ handoffToken: z.string().min(1), audienceApp: z.string().min(1).max(120).optional() }) const AVATAR_MAX_BYTES = 80 * 1024 const AVATAR_ALLOWED_MIME_TYPES = new Set(['image/webp', 'image/png', 'image/jpeg']) const userResponse = (user: AuthUser) => ({ id: user.id, email: user.email, name: user.name, role: user.role, roles: [...new Set([user.role, ...(user.appRoles ?? []).map((appRole) => appRole.roleCode)])], appRoles: user.appRoles ?? [], createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : user.createdAt, lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt, primaryAffiliation: user.primaryAffiliation ?? null, currentLicense: user.currentLicense ? { ...user.currentLicense, startsAt: user.currentLicense.startsAt instanceof Date ? user.currentLicense.startsAt.toISOString() : user.currentLicense.startsAt, expiresAt: user.currentLicense.expiresAt instanceof Date ? user.currentLicense.expiresAt.toISOString() : user.currentLicense.expiresAt } : null }) const requireAdmin = (user: AuthUser): boolean => user.role === 'ADMIN' const accessTokenResponse = async (user: AuthUser) => ({ accessToken: await signAccessToken(user), accessTokenExpiresIn: config.accessTokenTtlSeconds, user: userResponse(user) }) const tokenPairResponse = async (user: AuthUser) => ({ ...(await accessTokenResponse(user)), refreshToken: await createRefreshToken(user.id), refreshTokenExpiresIn: config.refreshTokenTtlSeconds }) const cookieAttributes = (maxAgeSeconds: number): string[] => [ 'HttpOnly', 'Path=/', 'SameSite=Lax', `Max-Age=${maxAgeSeconds}`, ...(config.refreshCookieSecure ? ['Secure'] : []) ] const setRefreshCookie = (c: { header: (name: string, value: string) => void }, refreshToken: string): void => { c.header( 'Set-Cookie', `${config.refreshCookieName}=${encodeURIComponent(refreshToken)}; ${cookieAttributes(config.refreshTokenTtlSeconds).join('; ')}` ) } const clearRefreshCookie = (c: { header: (name: string, value: string) => void }): void => { c.header('Set-Cookie', `${config.refreshCookieName}=; ${cookieAttributes(0).join('; ')}`) } const readCookie = (cookieHeader: string | undefined, name: string): string | undefined => { if (!cookieHeader) return undefined const prefix = `${name}=` const cookie = cookieHeader .split(';') .map((part) => part.trim()) .find((part) => part.startsWith(prefix)) return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : undefined } const readRefreshToken = async (c: { req: { header: (name: string) => string | undefined; json: () => Promise } }) => { const cookieToken = readCookie(c.req.header('cookie'), config.refreshCookieName) if (cookieToken) return cookieToken const body = refreshSchema.safeParse(await c.req.json().catch(() => ({}))) return body.success ? body.data.refreshToken : undefined } export const authRoutes = new Hono<{ Variables: AuthVariables }>() authRoutes.post('/email-verification/send', async (c) => { const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) const email = body.data.email.toLowerCase() const existingUser = await findUserByEmail(email) if (existingUser) return c.json({ error: 'email_already_registered' }, 409) const code = String(randomInt(100000, 1000000)) await storeEmailVerificationCode(email, code) try { await sendEmailVerificationCode(email, code) } catch (error) { console.error('Failed to send email verification code', error) return c.json({ error: 'email_send_failed' }, 502) } return c.json({ ok: true, expiresIn: config.emailVerificationTtlSeconds }) }) authRoutes.post('/password-reset/send', async (c) => { const body = passwordResetSendSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) const email = body.data.email.toLowerCase() const existingUser = await findUserByEmail(email) if (!existingUser?.isActive) { return c.json({ error: 'email_not_registered' }, 404) } const code = String(randomInt(100000, 1000000)) await storePasswordResetCode(email, code) try { await sendEmailVerificationCode(email, code) } catch (error) { console.error('Failed to send password reset code', error) return c.json({ error: 'email_send_failed' }, 502) } return c.json({ ok: true, expiresIn: config.emailVerificationTtlSeconds }) }) authRoutes.post('/password-reset/confirm', async (c) => { const body = passwordResetConfirmSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) const email = body.data.email.toLowerCase() const existingUser = await findUserByEmail(email) if (!existingUser?.isActive) return c.json({ error: 'invalid_password_reset_code' }, 400) const codeVerified = await verifyPasswordResetCode(email, body.data.verificationCode) if (!codeVerified) return c.json({ error: 'invalid_password_reset_code' }, 400) await updateUserPassword(existingUser.id, body.data.password) await clearLoginAttempts(email) return c.json({ ok: true }) }) authRoutes.post('/password/change', authRequired, async (c) => { const body = passwordChangeSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) const changed = await changeUserPassword( c.get('user').id, body.data.currentPassword, body.data.newPassword ) if (!changed) return c.json({ error: 'invalid_current_password' }, 400) return c.json({ ok: true }) }) authRoutes.post('/login', async (c) => { const body = loginSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) const email = body.data.email.toLowerCase() if (await isLoginBlocked(email)) return c.json({ error: 'too_many_login_attempts' }, 429) const dbUser = await findUserByEmail(email) if (!dbUser || !dbUser.isActive) { await recordFailedLogin(email) return c.json({ error: 'invalid_credentials' }, 401) } const passwordOk = await verifyPassword(body.data.password, dbUser.passwordHash) if (!passwordOk) { await recordFailedLogin(email) return c.json({ error: 'invalid_credentials' }, 401) } const loggedInAt = new Date() const user: AuthUser = { id: dbUser.id, email: dbUser.email, name: dbUser.name, role: dbUser.role, appRoles: dbUser.appRoles ?? [], createdAt: dbUser.createdAt, lastLoginAt: loggedInAt, primaryAffiliation: dbUser.affiliations?.[0] ? { id: dbUser.affiliations[0].id, organizationId: dbUser.affiliations[0].organizationId, organizationName: dbUser.affiliations[0].organization.name, organizationType: dbUser.affiliations[0].organization.type, department: dbUser.affiliations[0].department, position: dbUser.affiliations[0].position } : null, currentLicense: dbUser.userLicenses?.[0] ? { id: dbUser.userLicenses[0].id, code: dbUser.userLicenses[0].license.code, name: dbUser.userLicenses[0].license.name, startsAt: dbUser.userLicenses[0].startsAt, expiresAt: dbUser.userLicenses[0].expiresAt } : null } await markLastLogin(user.id, loggedInAt) await clearLoginAttempts(email) const refreshToken = await createRefreshToken(user.id) setRefreshCookie(c, refreshToken) return c.json(await accessTokenResponse(user)) }) authRoutes.post('/signup', async (c) => { const body = signupSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) const email = body.data.email.toLowerCase() const existingUser = await findUserByEmail(email) if (existingUser) return c.json({ error: 'email_already_registered' }, 409) const emailVerified = await verifyEmailVerificationCode(email, body.data.verificationCode) if (!emailVerified) return c.json({ error: 'invalid_email_verification_code' }, 400) const user = await createUser({ email, password: body.data.password, name: body.data.name }) const refreshToken = await createRefreshToken(user.id) setRefreshCookie(c, refreshToken) return c.json(await accessTokenResponse(user), 201) }) authRoutes.post('/refresh', async (c) => { const refreshToken = await readRefreshToken(c) if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401) const user = await rotateRefreshToken(refreshToken) if (!user) { clearRefreshCookie(c) return c.json({ error: 'invalid_refresh_token' }, 401) } const nextRefreshToken = await createRefreshToken(user.id) setRefreshCookie(c, nextRefreshToken) return c.json(await accessTokenResponse(user)) }) authRoutes.post('/logout', async (c) => { const body = logoutSchema.safeParse(await c.req.json().catch(() => ({}))) const refreshToken = body.success && body.data.refreshToken ? body.data.refreshToken : readCookie(c.req.header('cookie'), config.refreshCookieName) if (refreshToken) { await revokeRefreshToken(refreshToken) } clearRefreshCookie(c) return c.json({ ok: true }) }) authRoutes.get('/me', authRequired, (c) => { return c.json({ user: userResponse(c.get('user')) }) }) authRoutes.get('/profile/avatar', authRequired, async (c) => { const image = await findUserProfileImage(c.get('user').id) if (!image) return c.body(null, 404) c.header('content-type', image.mimeType) c.header('cache-control', 'no-store') if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString()) return c.body(new Uint8Array(image.data)) }) authRoutes.get('/users/:userId/avatar', authRequired, async (c) => { if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) const userId = c.req.param('userId') const image = await findUserProfileImage(userId) if (!image) return c.body(null, 404) c.header('content-type', image.mimeType) c.header('cache-control', 'no-store') if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString()) return c.body(new Uint8Array(image.data)) }) authRoutes.get('/users/avatar', authRequired, async (c) => { if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) const email = c.req.query('email')?.toLowerCase() if (!email) return c.json({ error: 'email_required' }, 400) const user = await findUserByEmail(email) if (!user) return c.body(null, 404) const image = await findUserProfileImage(user.id) if (!image) return c.body(null, 404) c.header('content-type', image.mimeType) c.header('cache-control', 'no-store') if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString()) return c.body(new Uint8Array(image.data)) }) authRoutes.put('/profile/avatar', authRequired, async (c) => { const contentType = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase() ?? '' if (!AVATAR_ALLOWED_MIME_TYPES.has(contentType)) { return c.json({ error: 'unsupported_image_type' }, 415) } const body = new Uint8Array(await c.req.arrayBuffer()) if (body.length === 0) return c.json({ error: 'empty_image' }, 400) if (body.length > AVATAR_MAX_BYTES) return c.json({ error: 'image_too_large', maxBytes: AVATAR_MAX_BYTES }, 413) await updateUserProfileImage(c.get('user').id, body, contentType) return c.json({ ok: true }) }) authRoutes.delete('/profile/avatar', authRequired, async (c) => { await deleteUserProfileImage(c.get('user').id) return c.json({ ok: true }) }) authRoutes.get('/admin/organizations', authRequired, async (c) => { if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) const organizations = await listOrganizations() return c.json({ organizations: organizations.map((organization) => ({ ...organization, createdAt: organization.createdAt.toISOString() })) }) }) authRoutes.post('/admin/organizations', authRequired, async (c) => { if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) const body = createOrganizationSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) try { const organization = await createOrganization(body.data) return c.json({ organization: { ...organization, createdAt: organization.createdAt.toISOString() } }, 201) } catch { return c.json({ error: 'organization_already_exists' }, 409) } }) authRoutes.post('/admin/users', authRequired, async (c) => { if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) const body = adminCreateUserSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) const email = body.data.email.toLowerCase() const existingUser = await findUserByEmail(email) if (existingUser) return c.json({ error: 'email_already_registered' }, 409) try { const user = await createManagedUser({ email, password: body.data.password, name: body.data.name, organizationId: body.data.organizationId, department: body.data.department, position: body.data.position, licenseCode: body.data.licenseCode }) return c.json({ user: userResponse(user) }, 201) } catch { return c.json({ error: 'user_create_failed' }, 400) } }) authRoutes.post('/admin/users/:userId/affiliations', authRequired, async (c) => { if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) const userId = c.req.param('userId') const body = assignAffiliationSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) try { const affiliation = await assignUserAffiliation({ userId, organizationId: body.data.organizationId, department: body.data.department, position: body.data.position, isPrimary: body.data.isPrimary }) return c.json({ affiliation }, 201) } catch { return c.json({ error: 'affiliation_assign_failed' }, 400) } }) authRoutes.post('/handoff', authRequired, async (c) => { const body = handoffSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) const token = await createHandoffToken(c.get('user').id, body.data.audienceApp) return c.json({ handoffToken: token, expiresIn: config.handoffTokenTtlSeconds, audienceApp: body.data.audienceApp }) }) authRoutes.post('/handoff/exchange', async (c) => { const body = handoffExchangeSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) const user = await exchangeHandoffToken(body.data.handoffToken, body.data.audienceApp) if (!user) return c.json({ error: 'invalid_handoff_token' }, 401) return c.json(await tokenPairResponse(user)) })