import { config } from '../config' import { prisma } from '../prisma' import { deletePublicFile, isR2PublicFilesConfigured, publicFileUrl, uploadPublicFile, userAvatarKey } from '../storage/r2-public-files' import { hashPassword, verifyPassword } from '../security/password' import { redis } from '../redis' import { randomBytes } from 'crypto' 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 { 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) } export async function createInstructorUser(input: { email: string password: string name?: string | null instructorInviteCode: string }): Promise { return prisma.$transaction(async (tx) => { const organization = await tx.organization.findFirst({ where: { isActive: true, instructorInviteCode: input.instructorInviteCode.trim().toUpperCase() }, select: { id: true, defaultLicenseCode: true } }) if (!organization) return null const requestedLicenseCode = organization.defaultLicenseCode ?? 'FREE' const license = await tx.license.findFirst({ where: { code: requestedLicenseCode as 'FREE' | 'EDU' | 'PRO', isActive: true }, select: { code: true } }) const user = await tx.user.create({ data: { email: input.email.toLowerCase(), passwordHash: await hashPassword(input.password), name: input.name?.trim() || null, affiliations: { create: { organizationId: organization.id, isPrimary: true } }, appRoles: { create: { appKey: 'LMS', roleCode: 'INSTRUCTOR' } }, userLicenses: { create: { license: { connect: { code: license?.code ?? 'FREE' } } } } }, select: userSelect() }) return toAuthUser(user) }) } export async function findOrCreateOAuthUser(input: { provider: string providerUserId: string email: string name?: string | null }): Promise { const provider = input.provider.toLowerCase() const email = input.email.toLowerCase() const oauthAccount = await prisma.userOauthAccount.findUnique({ where: { provider_providerUserId: { provider, providerUserId: input.providerUserId } }, include: { user: { select: userSelect() } } }) if (oauthAccount?.user) return toAuthUserEnsuringFreeLicense(oauthAccount.user) const existingUser = await prisma.user.findUnique({ where: { email }, select: { id: true } }) if (existingUser) { const user = await prisma.user.update({ where: { id: existingUser.id }, data: { oauthAccounts: { create: { provider, providerUserId: input.providerUserId, email } } }, select: userSelect() }) return toAuthUserEnsuringFreeLicense(user) } 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, passwordHash: await hashPassword(createOpaqueToken()), name: input.name?.trim() || null, oauthAccounts: { create: { provider, providerUserId: input.providerUserId, email } }, userLicenses: { create: { startsAt: licenseStartsAt, expiresAt: licenseExpiresAt, license: { connect: { code: 'FREE' } } } } }, select: userSelect() }) return toAuthUser(user) } async function toAuthUserEnsuringFreeLicense(user: UserWithAffiliation): Promise { if (user.userLicenses?.[0]) return toAuthUser(user) await ensureFreeLicense(user.id) return (await findUserById(user.id)) ?? toAuthUser(user) } async function ensureFreeLicense(userId: string): Promise { const activeLicense = await prisma.userLicense.findFirst({ where: { userId, ...activeLicenseWhere() }, select: { id: true } }) if (activeLicense) return const freeLicense = await prisma.license.findUnique({ where: { code: 'FREE' }, select: { id: true, defaultDurationDays: true } }) if (!freeLicense) throw new Error('FREE license is not configured') const licenseStartsAt = new Date() const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense.defaultDurationDays) await prisma.userLicense.create({ data: { userId, licenseId: freeLicense.id, startsAt: licenseStartsAt, expiresAt: licenseExpiresAt } }) } 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 { 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 { await prisma.user.update({ where: { id: userId }, data: { passwordHash: await hashPassword(password) } }) } export async function changeUserPassword(userId: string, currentPassword: string, newPassword: string): Promise { 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 { if (isR2PublicFilesConfigured()) { await uploadPublicFile({ key: userAvatarKey(userId), body: imageData, contentType: mimeType }) await prisma.user.update({ where: { id: userId }, data: { profileImageData: null, profileImageMime: mimeType, profileImageUpdatedAt: new Date() } }) return } await prisma.user.update({ where: { id: userId }, data: { profileImageData: Buffer.from(imageData), profileImageMime: mimeType, profileImageUpdatedAt: new Date() } }) } export async function deleteUserProfileImage(userId: string): Promise { await deletePublicFile(userAvatarKey(userId)).catch(() => undefined) await prisma.user.update({ where: { id: userId }, data: { profileImageData: null, profileImageMime: null, profileImageUpdatedAt: null } }) } export async function findUserProfileImage(userId: string): Promise<{ data?: Buffer; url?: string; 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?.profileImageMime) return null const storedUrl = publicFileUrl(userAvatarKey(userId)) if (!user.profileImageData && storedUrl) { return { url: storedUrl, mimeType: user.profileImageMime, updatedAt: user.profileImageUpdatedAt } } if (!user.profileImageData) return null return { data: Buffer.from(user.profileImageData), mimeType: user.profileImageMime, updatedAt: user.profileImageUpdatedAt } } export async function findUserById(id: string): Promise { 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 }) { const instructorInviteCode = await createInstructorInviteCode() return prisma.organization.create({ data: { name: input.name.trim(), type: input.type, domain: input.domain?.trim() || null, instructorInviteCode }, select: { id: true, name: true, type: true, domain: true, createdAt: true } }) } async function createInstructorInviteCode(): Promise { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' for (let attempt = 0; attempt < 10; attempt += 1) { const code = Array.from(randomBytes(6), (byte) => alphabet[byte % alphabet.length]).join('') const existing = await prisma.organization.findUnique({ where: { instructorInviteCode: code }, select: { id: true } }) if (!existing) return code } throw new Error('failed_to_generate_instructor_invite_code') } 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 { await prisma.user.update({ where: { id: userId }, data: { lastLoginAt: loggedInAt } }) } export async function isLoginBlocked(email: string): Promise { const attempts = Number(await redis.get(loginAttemptKey(email))) return Number.isFinite(attempts) && attempts >= config.maxFailedLoginAttempts } export async function recordFailedLogin(email: string): Promise { const attempts = await redis.incr(loginAttemptKey(email)) if (attempts === 1) { await redis.expire(loginAttemptKey(email), config.loginAttemptWindowSeconds) } } export async function clearLoginAttempts(email: string): Promise { await redis.del(loginAttemptKey(email)) } export async function storeEmailVerificationCode(email: string, code: string): Promise { await redis.set( emailVerificationKey(email), JSON.stringify({ code, email: email.toLowerCase() }), 'EX', config.emailVerificationTtlSeconds ) } export async function verifyEmailVerificationCode(email: string, code: string): Promise { 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 { await redis.set( passwordResetKey(email), JSON.stringify({ code, email: email.toLowerCase() }), 'EX', config.emailVerificationTtlSeconds ) } export async function verifyPasswordResetCode(email: string, code: string): Promise { 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 { const token = createOpaqueToken() const expiresAt = new Date(Date.now() + config.refreshTokenTtlSeconds * 1000) await redis.set(refreshTokenKey(token), JSON.stringify({ userId }), 'EX', config.refreshTokenTtlSeconds) await prisma.$transaction([ prisma.refreshToken.updateMany({ where: { userId, revokedAt: null, expiresAt: { gt: new Date() } }, data: { revokedAt: new Date() } }), prisma.refreshToken.create({ data: { userId, tokenHash: hashToken(token), expiresAt } }) ]) return token } export async function rotateRefreshToken(refreshToken: string): Promise { const tokenHash = hashToken(refreshToken) const tokenJson = await redis.send('GETDEL', [refreshTokenKey(refreshToken)]) if (typeof tokenJson !== 'string') return null const token = parseStoredRefreshToken(tokenJson) if (!token) return null const storedToken = await prisma.refreshToken.findUnique({ where: { tokenHash }, select: { id: true, userId: true, revokedAt: true, expiresAt: true } }) if (!storedToken || storedToken.userId !== token.userId || storedToken.revokedAt || storedToken.expiresAt <= new Date()) { return null } await prisma.refreshToken.update({ where: { id: storedToken.id }, data: { revokedAt: new Date() } }) return findUserById(token.userId) } export async function createOAuthState(redirectUri: string): Promise { const state = createOpaqueToken() await redis.set(oauthStateKey(state), JSON.stringify({ redirectUri }), 'EX', 10 * 60) return state } export async function consumeOAuthState(state: string): Promise<{ redirectUri: string } | null> { const tokenJson = await redis.send('GETDEL', [oauthStateKey(state)]) if (!tokenJson) return null try { const payload = JSON.parse(String(tokenJson)) as { redirectUri?: unknown } return typeof payload.redirectUri === 'string' ? { redirectUri: payload.redirectUri } : null } catch { return null } } export async function createOAuthLoginToken(userId: string): Promise { const token = createOpaqueToken() await redis.set(oauthLoginTokenKey(token), JSON.stringify({ userId }), 'EX', config.oauthLoginTokenTtlSeconds) return token } export async function exchangeOAuthLoginToken(loginToken: string): Promise { const tokenJson = await redis.send('GETDEL', [oauthLoginTokenKey(loginToken)]) if (!tokenJson) return null try { const payload = JSON.parse(String(tokenJson)) as { userId?: unknown } return typeof payload.userId === 'string' ? findUserById(payload.userId) : null } catch { return null } } export async function revokeRefreshToken(refreshToken: string): Promise { await Promise.all([ redis.del(refreshTokenKey(refreshToken)), prisma.refreshToken.updateMany({ where: { tokenHash: hashToken(refreshToken), revokedAt: null }, data: { revokedAt: new Date() } }) ]) } export async function revokeRefreshTokensForUser(userId: string): Promise { await prisma.refreshToken.updateMany({ where: { userId, revokedAt: null, expiresAt: { gt: new Date() } }, data: { revokedAt: new Date() } }) } export async function createHandoffToken(userId: string, audienceApp: string): Promise { 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 { 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 oauthStateKey = (state: string): string => `auth:oauth:state:${hashToken(state)}` const oauthLoginTokenKey = (token: string): string => `auth:oauth:login:${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 } }