init studio auth backend

This commit is contained in:
2026-07-01 17:36:34 +09:00
commit 87f6f46d3c
35 changed files with 2492 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import type { MiddlewareHandler } from 'hono'
import { findUserById } from './repository'
import { verifyAccessToken } from '../security/tokens'
import type { AuthUser } from '../types'
export type AuthVariables = {
user: AuthUser
}
export const authRequired: MiddlewareHandler<{ Variables: AuthVariables }> = async (c, next) => {
const header = c.req.header('authorization')
const token = header?.match(/^Bearer\s+(.+)$/i)?.[1]
if (!token) {
return c.json({ error: 'missing_authorization' }, 401)
}
try {
const claims = await verifyAccessToken(token)
const user = await findUserById(claims.sub)
if (!user) return c.json({ error: 'invalid_authorization' }, 401)
c.set('user', user)
return next()
} catch {
return c.json({ error: 'invalid_authorization' }, 401)
}
}
+534
View File
@@ -0,0 +1,534 @@
import { config } from '../config'
import { prisma } from '../prisma'
import { hashPassword, verifyPassword } from '../security/password'
import { redis } from '../redis'
import { createOpaqueToken, hashToken } from '../security/tokens'
import type { AuthUser } from '../types'
type UserWithAffiliation = {
id: string
email: string
name: string | null
role: string
createdAt: Date
lastLoginAt: Date | null
appRoles?: Array<{
appKey: string
roleCode: string
}>
affiliations?: Array<{
id: string
organizationId: string
department: string | null
position: string | null
organization: {
name: string
type: string
}
}>
userLicenses?: Array<{
id: string
startsAt: Date
expiresAt: Date | null
license: {
code: string
name: string
}
}>
}
const activeLicenseWhere = () => {
const now = new Date()
return {
startsAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
license: { isActive: true }
}
}
const currentLicenseRelation = () => ({
where: activeLicenseWhere(),
orderBy: { startsAt: 'desc' },
take: 1,
select: {
id: true,
startsAt: true,
expiresAt: true,
license: {
select: {
code: true,
name: true
}
}
}
}) as const
const userSelect = () => {
const now = new Date()
return {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
lastLoginAt: true,
affiliations: {
where: { isPrimary: true, endsAt: null },
take: 1,
select: {
id: true,
organizationId: true,
department: true,
position: true,
organization: {
select: {
name: true,
type: true
}
}
}
},
userLicenses: currentLicenseRelation(),
appRoles: {
where: {
AND: [
{ OR: [{ startsAt: null }, { startsAt: { lte: now } }] },
{ OR: [{ endsAt: null }, { endsAt: { gt: now } }] }
]
},
select: {
appKey: true,
roleCode: true
}
}
}
}
const toAuthUser = (user: UserWithAffiliation): AuthUser => {
const primaryAffiliation = user.affiliations?.[0]
const currentLicense = user.userLicenses?.[0]
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
appRoles: user.appRoles ?? [],
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
primaryAffiliation: primaryAffiliation
? {
id: primaryAffiliation.id,
organizationId: primaryAffiliation.organizationId,
organizationName: primaryAffiliation.organization.name,
organizationType: primaryAffiliation.organization.type,
department: primaryAffiliation.department,
position: primaryAffiliation.position
}
: null,
currentLicense: currentLicense
? {
id: currentLicense.id,
code: currentLicense.license.code,
name: currentLicense.license.name,
startsAt: currentLicense.startsAt,
expiresAt: currentLicense.expiresAt
}
: null
}
}
export async function findUserByEmail(email: string) {
return prisma.user.findUnique({
where: { email: email.toLowerCase() },
include: {
affiliations: {
where: { isPrimary: true, endsAt: null },
take: 1,
include: { organization: true }
},
userLicenses: {
where: activeLicenseWhere(),
orderBy: { startsAt: 'desc' },
take: 1,
include: { license: true }
},
appRoles: {
where: {
AND: [
{ OR: [{ startsAt: null }, { startsAt: { lte: new Date() } }] },
{ OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }] }
]
},
select: {
appKey: true,
roleCode: true
}
}
}
})
}
export async function createUser(input: { email: string; password: string; name?: string | null }): Promise<AuthUser> {
const freeLicense = await prisma.license.findUnique({
where: { code: 'FREE' },
select: { defaultDurationDays: true }
})
const licenseStartsAt = new Date()
const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense?.defaultDurationDays)
const user = await prisma.user.create({
data: {
email: input.email.toLowerCase(),
passwordHash: await hashPassword(input.password),
name: input.name?.trim() || null,
userLicenses: {
create: {
startsAt: licenseStartsAt,
expiresAt: licenseExpiresAt,
license: {
connect: { code: 'FREE' }
}
}
}
},
select: userSelect()
})
return toAuthUser(user)
}
function calculateLicenseExpiresAt(startsAt: Date, durationDays: number | null | undefined): Date | null {
if (!durationDays) return null
const expiresAt = new Date(startsAt)
expiresAt.setDate(expiresAt.getDate() + durationDays)
return expiresAt
}
export async function createManagedUser(input: {
email: string
password: string
name?: string | null
organizationId: string
department?: string | null
position?: string | null
licenseCode?: 'FREE' | 'EDU' | 'PRO'
}): Promise<AuthUser> {
const user = await prisma.user.create({
data: {
email: input.email.toLowerCase(),
passwordHash: await hashPassword(input.password),
name: input.name?.trim() || null,
role: 'USER',
affiliations: {
create: {
organizationId: input.organizationId,
department: input.department?.trim() || null,
position: input.position?.trim() || null,
isPrimary: true
}
},
userLicenses: {
create: {
license: {
connect: { code: input.licenseCode ?? 'EDU' }
}
}
}
},
select: userSelect()
})
return toAuthUser(user)
}
export async function updateUserPassword(userId: string, password: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { passwordHash: await hashPassword(password) }
})
}
export async function changeUserPassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
const user = await prisma.user.findFirst({
where: { id: userId, isActive: true },
select: { passwordHash: true }
})
if (!user) return false
const passwordOk = await verifyPassword(currentPassword, user.passwordHash)
if (!passwordOk) return false
await updateUserPassword(userId, newPassword)
return true
}
export async function updateUserProfileImage(userId: string, imageData: Uint8Array, mimeType: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
profileImageData: Buffer.from(imageData),
profileImageMime: mimeType,
profileImageUpdatedAt: new Date()
}
})
}
export async function deleteUserProfileImage(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
profileImageData: null,
profileImageMime: null,
profileImageUpdatedAt: null
}
})
}
export async function findUserProfileImage(userId: string): Promise<{ data: Buffer; mimeType: string; updatedAt: Date | null } | null> {
const user = await prisma.user.findFirst({
where: { id: userId, isActive: true },
select: {
profileImageData: true,
profileImageMime: true,
profileImageUpdatedAt: true
}
})
if (!user?.profileImageData || !user.profileImageMime) return null
return {
data: Buffer.from(user.profileImageData),
mimeType: user.profileImageMime,
updatedAt: user.profileImageUpdatedAt
}
}
export async function findUserById(id: string): Promise<AuthUser | null> {
const user = await prisma.user.findFirst({
where: { id, isActive: true },
select: userSelect()
})
return user ? toAuthUser(user) : null
}
export async function listOrganizations() {
return prisma.organization.findMany({
where: { isActive: true },
orderBy: [{ type: 'asc' }, { name: 'asc' }],
select: {
id: true,
name: true,
type: true,
domain: true,
createdAt: true
}
})
}
export async function createOrganization(input: { name: string; type: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; domain?: string | null }) {
return prisma.organization.create({
data: {
name: input.name.trim(),
type: input.type,
domain: input.domain?.trim() || null
},
select: {
id: true,
name: true,
type: true,
domain: true,
createdAt: true
}
})
}
export async function assignUserAffiliation(input: {
userId: string
organizationId: string
department?: string | null
position?: string | null
isPrimary?: boolean
}) {
return prisma.$transaction(async (tx) => {
if (input.isPrimary) {
await tx.userAffiliation.updateMany({
where: { userId: input.userId, endsAt: null },
data: { isPrimary: false }
})
}
return tx.userAffiliation.create({
data: {
userId: input.userId,
organizationId: input.organizationId,
department: input.department?.trim() || null,
position: input.position?.trim() || null,
isPrimary: input.isPrimary ?? true
},
select: {
id: true,
userId: true,
organizationId: true,
department: true,
position: true,
isPrimary: true,
organization: {
select: {
name: true,
type: true
}
}
}
})
})
}
export async function markLastLogin(userId: string, loggedInAt = new Date()): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { lastLoginAt: loggedInAt }
})
}
export async function isLoginBlocked(email: string): Promise<boolean> {
const attempts = Number(await redis.get(loginAttemptKey(email)))
return Number.isFinite(attempts) && attempts >= config.maxFailedLoginAttempts
}
export async function recordFailedLogin(email: string): Promise<void> {
const attempts = await redis.incr(loginAttemptKey(email))
if (attempts === 1) {
await redis.expire(loginAttemptKey(email), config.loginAttemptWindowSeconds)
}
}
export async function clearLoginAttempts(email: string): Promise<void> {
await redis.del(loginAttemptKey(email))
}
export async function storeEmailVerificationCode(email: string, code: string): Promise<void> {
await redis.set(
emailVerificationKey(email),
JSON.stringify({ code, email: email.toLowerCase() }),
'EX',
config.emailVerificationTtlSeconds
)
}
export async function verifyEmailVerificationCode(email: string, code: string): Promise<boolean> {
const key = emailVerificationKey(email)
const tokenJson = await redis.get(key)
if (typeof tokenJson !== 'string') return false
const token = parseStoredEmailVerification(tokenJson)
if (!token || token.email !== email.toLowerCase() || token.code !== code) return false
await redis.del(key)
return true
}
export async function storePasswordResetCode(email: string, code: string): Promise<void> {
await redis.set(
passwordResetKey(email),
JSON.stringify({ code, email: email.toLowerCase() }),
'EX',
config.emailVerificationTtlSeconds
)
}
export async function verifyPasswordResetCode(email: string, code: string): Promise<boolean> {
const key = passwordResetKey(email)
const tokenJson = await redis.get(key)
if (typeof tokenJson !== 'string') return false
const token = parseStoredEmailVerification(tokenJson)
if (!token || token.email !== email.toLowerCase() || token.code !== code) return false
await redis.del(key)
return true
}
export async function createRefreshToken(userId: string): Promise<string> {
const token = createOpaqueToken()
await redis.set(refreshTokenKey(token), JSON.stringify({ userId }), 'EX', config.refreshTokenTtlSeconds)
return token
}
export async function rotateRefreshToken(refreshToken: string): Promise<AuthUser | null> {
const tokenJson = await redis.send('GETDEL', [refreshTokenKey(refreshToken)])
if (typeof tokenJson !== 'string') return null
const token = parseStoredRefreshToken(tokenJson)
if (!token) return null
return findUserById(token.userId)
}
export async function revokeRefreshToken(refreshToken: string): Promise<void> {
await redis.del(refreshTokenKey(refreshToken))
}
export async function createHandoffToken(userId: string, audienceApp: string): Promise<string> {
const token = createOpaqueToken()
await redis.set(
handoffTokenKey(token),
JSON.stringify({ userId, audienceApp }),
'EX',
config.handoffTokenTtlSeconds
)
return token
}
export async function exchangeHandoffToken(handoffToken: string, audienceApp?: string): Promise<AuthUser | null> {
const tokenJson = await redis.send('GETDEL', [handoffTokenKey(handoffToken)])
if (typeof tokenJson !== 'string') return null
const token = parseStoredHandoffToken(tokenJson)
if (!token || (audienceApp && token.audienceApp !== audienceApp)) return null
return findUserById(token.userId)
}
const refreshTokenKey = (token: string): string => `auth:refresh:${hashToken(token)}`
const handoffTokenKey = (token: string): string => `auth:handoff:${hashToken(token)}`
const loginAttemptKey = (email: string): string => `auth:login_attempt:${hashToken(email.toLowerCase())}`
const emailVerificationKey = (email: string): string => `auth:email_verify:${hashToken(email.toLowerCase())}`
const passwordResetKey = (email: string): string => `auth:password_reset:${hashToken(email.toLowerCase())}`
const parseStoredRefreshToken = (value: string): { userId: string } | null => {
try {
const parsed = JSON.parse(value)
return typeof parsed.userId === 'string' ? { userId: parsed.userId } : null
} catch {
return null
}
}
const parseStoredHandoffToken = (value: string): { userId: string; audienceApp: string } | null => {
try {
const parsed = JSON.parse(value)
return typeof parsed.userId === 'string' && typeof parsed.audienceApp === 'string'
? { userId: parsed.userId, audienceApp: parsed.audienceApp }
: null
} catch {
return null
}
}
const parseStoredEmailVerification = (value: string): { email: string; code: string } | null => {
try {
const parsed = JSON.parse(value)
return typeof parsed.email === 'string' && typeof parsed.code === 'string'
? { email: parsed.email, code: parsed.code }
: null
} catch {
return null
}
}
+555
View File
@@ -0,0 +1,555 @@
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<unknown> } }) => {
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))
})