import { isRecord } from '@/shared/object'; import logger from '@/shared/logger'; import crypto from 'crypto'; import bcrypt from 'bcrypt'; import type { Request } from 'express'; import UsersDBApi from '@/db/api/users'; import AuthRefreshTokensDBApi from '@/db/api/auth_refresh_tokens'; import ValidationError from '@/shared/errors/validation'; import ForbiddenError from '@/shared/errors/forbidden'; import EmailAddressVerificationEmail from '@/services/email/list/addressVerification'; import InvitationEmail from '@/services/email/list/invitation'; import PasswordResetEmail from '@/services/email/list/passwordReset'; import EmailSender from '@/services/email'; import config from '@/shared/config'; import { USER_NAME_PREFIX_VALUES, type UserNamePrefix, } from '@/shared/constants/users'; import { jwtSign } from '@/shared/jwt'; import db from '@/db/models'; import type { AuthenticatedUser, CurrentUser, DbApiOptions, FileInput, } from '@/db/api/types'; import type { SessionOptions, RoleDto, OrganizationDto, CampusDto, } from '@/services/auth.types'; type PlainRecord = Record; /** Minimal user shape needed to mint a session (accepts a record or instance). */ type SessionUser = Pick; type CreateFromAuthData = Parameters[0]; type UpdateProfileData = Parameters[1]; function asStringOrNull(value: unknown): string | null { return typeof value === 'string' ? value : null; } function asId(value: unknown): string { return typeof value === 'string' ? value : ''; } function frontendUrl(host: string | undefined, path: string): string { const fallbackBase = config.uiUrl || config.backUrl || 'http://localhost:3000'; const base = host ? (/^https?:\/\//i.test(host) ? host : `https://${host}`) : fallbackBase; return new URL(path, base.endsWith('/') ? base : `${base}/`).toString(); } /** Returns the plain attributes of a Sequelize instance, or the record itself. */ function toPlainRecord(record: unknown): PlainRecord | null { if (!isRecord(record)) { return null; } if (typeof record.get === 'function') { const plain: unknown = record.get({ plain: true }); return isRecord(plain) ? plain : null; } return record; } function toRoleDto(role: unknown): RoleDto | null { const plain = toPlainRecord(role); if (!plain) return null; return { id: asStringOrNull(plain.id), name: asStringOrNull(plain.name), scope: asStringOrNull(plain.scope), globalAccess: plain.globalAccess === true, }; } function toOrganizationDto(organization: unknown): OrganizationDto | null { const plain = toPlainRecord(organization); const id = asStringOrNull(plain?.id); if (!plain || !id) return null; return { id, name: asStringOrNull(plain.name), logo: asStringOrNull(plain.logo), esaEnabled: plain.esaEnabled !== false, }; } function toCampusDto(campus: unknown): CampusDto | null { const plain = toPlainRecord(campus); const id = asStringOrNull(plain?.id); if (!plain || !id) return null; return { id, name: asStringOrNull(plain.name), code: asStringOrNull(plain.code), logo: asStringOrNull(plain.logo), }; } /** Minimal tenant identity for the active-scope badge / selectors. */ function toTenantDto( value: unknown, ): { id: string; name: string | null; logo: string | null } | null { const plain = toPlainRecord(value); const id = asStringOrNull(plain?.id); if (!plain || !id) return null; return { id, name: asStringOrNull(plain.name), logo: asStringOrNull(plain.logo), }; } function permissionNamesOf(value: unknown): string[] { return (Array.isArray(value) ? value : []) .map((permission) => toPlainRecord(permission)) .filter( (permission): permission is { name: string } => isRecord(permission) && typeof permission.name === 'string', ) .map((permission) => permission.name); } /** * Effective permissions = (role ∪ per-user grants) − per-user exclusions. The * `custom_permissions_filter` lets a manager remove specific permissions from a * user that their role would otherwise grant. */ function getPermissionNames( rolePermissions: unknown, customPermissions: unknown, filterPermissions: unknown = [], ): string[] { const granted = new Set([ ...permissionNamesOf(rolePermissions), ...permissionNamesOf(customPermissions), ]); for (const excluded of permissionNamesOf(filterPermissions)) { granted.delete(excluded); } return [...granted]; } function getTokenPayload(user: SessionUser) { return { user: { id: user.id, email: user.email, }, }; } function getRequestUserAgent(req: Request): string | null { const header = req.headers['user-agent']; return typeof header === 'string' ? header : null; } function getRequestIp(req: Request): string | null { return req.ip || req.socket?.remoteAddress || null; } function generateOpaqueRefreshToken(): string { return crypto .randomBytes(config.auth.refreshTokenBytes) .toString('base64url'); } function hashRefreshToken(token: string): string { return crypto .createHash(config.auth.refreshTokenHashAlgorithm) .update(token) .digest('hex'); } function getRefreshTokenExpiry(): Date { return new Date(Date.now() + config.auth.refreshTokenMaxAgeMs); } class Auth { static async currentUserProfile(currentUser?: CurrentUser) { if (!currentUser || !currentUser.id) { throw new ForbiddenError(); } const user = await UsersDBApi.findProfileById(currentUser.id); if (!user) { throw new ForbiddenError(); } const campusDto = toCampusDto(user.campus); return { id: user.id, email: user.email, name_prefix: user.name_prefix ?? null, firstName: user.firstName, lastName: user.lastName, phoneNumber: user.phoneNumber, avatar: user.avatar?.[0]?.privateUrl ?? null, organizationId: user.organizationId, organizations: toOrganizationDto(user.organizations), app_role: toRoleDto(user.app_role), campus: campusDto, campusId: user.campusId ?? campusDto?.id ?? null, // Active-scope tenant chain (for the dynamic badge + scope context). school: toTenantDto(user.school), schoolId: user.schoolId ?? null, classRoom: toTenantDto(user.class), classId: user.classId ?? null, permissions: getPermissionNames( user.app_role_permissions, user.custom_permissions, user.custom_permissions_filter, ), }; } static async createSession( user: SessionUser, req: Request, options: SessionOptions = {}, ) { const refreshToken = generateOpaqueRefreshToken(); const familyId = options.familyId || crypto.randomUUID(); const tokenRecord = await AuthRefreshTokensDBApi.create( { userId: asId(user.id), organizationId: asStringOrNull(user.organizationId), tokenHash: hashRefreshToken(refreshToken), familyId, previousTokenId: options.previousTokenId || null, userAgent: getRequestUserAgent(req), ipAddress: getRequestIp(req), expiresAt: getRefreshTokenExpiry(), }, options, ); return { accessToken: jwtSign(getTokenPayload(user)), refreshToken, refreshTokenRecord: tokenRecord, user, }; } static async refreshSession(refreshToken: string | undefined, req: Request) { if (!refreshToken) { throw new ForbiddenError(); } const tokenHash = hashRefreshToken(refreshToken); const transaction = await db.sequelize.transaction(); let committed = false; try { const existingToken = await AuthRefreshTokensDBApi.findByHash(tokenHash, { transaction, }); if (!existingToken) { throw new ForbiddenError(); } if (existingToken.revokedAt) { await AuthRefreshTokensDBApi.revokeFamily(existingToken.familyId, { transaction, }); await transaction.commit(); committed = true; throw new ForbiddenError(); } if (new Date(existingToken.expiresAt).getTime() <= Date.now()) { await AuthRefreshTokensDBApi.revoke(existingToken.id, null, { transaction, }); await transaction.commit(); committed = true; throw new ForbiddenError(); } const user = await UsersDBApi.findBy({ id: existingToken.userId }); if (!user || user.disabled) { await AuthRefreshTokensDBApi.revokeFamily(existingToken.familyId, { transaction, }); await transaction.commit(); committed = true; throw new ForbiddenError(); } const nextSession = await this.createSession(user, req, { familyId: existingToken.familyId, previousTokenId: existingToken.id, transaction, }); await AuthRefreshTokensDBApi.revoke( existingToken.id, nextSession.refreshTokenRecord.id, { transaction }, ); await transaction.commit(); committed = true; return nextSession; } catch (error) { if (!committed) { await transaction.rollback(); } throw error; } } static async revokeSession(refreshToken: string | undefined) { if (!refreshToken) { return; } const tokenRecord = await AuthRefreshTokensDBApi.findByHash( hashRefreshToken(refreshToken), ); if (!tokenRecord || tokenRecord.revokedAt) { return; } await AuthRefreshTokensDBApi.revoke(tokenRecord.id, null); } static async signup( email: string, password: string, organizationId: string | null, options: DbApiOptions = {}, host?: string, ) { const user = await UsersDBApi.findBy({ email }); const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds); if (user) { if (user.disabled) { throw new ValidationError('auth.userDisabled'); } await UsersDBApi.updatePassword(asId(user.id), hashedPassword, options); if (EmailSender.isConfigured) { await this.sendEmailAddressVerificationEmail(email, host); } return { user }; } const newUser = await UsersDBApi.createFromAuth( { firstName: email.split('@')[0], password: hashedPassword, email, organizationId, } satisfies CreateFromAuthData, options, ); if (EmailSender.isConfigured) { await this.sendEmailAddressVerificationEmail(email, host); } return { user: newUser }; } static async signin(email: string, password: string) { const user = await UsersDBApi.findBy({ email }); if (!user) { throw new ValidationError('auth.userNotFound'); } if (user.disabled) { throw new ValidationError('auth.userDisabled'); } if (!user.password) { throw new ValidationError('auth.wrongPassword'); } if (!EmailSender.isConfigured) { user.emailVerified = true; } if (!user.emailVerified) { throw new ValidationError('auth.userNotVerified'); } const passwordsMatch = await bcrypt.compare(password, String(user.password)); if (!passwordsMatch) { throw new ValidationError('auth.wrongPassword'); } return { user }; } static async sendEmailAddressVerificationEmail(email: string, host?: string) { let link: string; try { const token = await UsersDBApi.generateEmailVerificationToken(email); link = frontendUrl(host, `/verify-email?token=${token}`); } catch (error) { logger.error('Failed to generate email verification token', error); throw new ValidationError('auth.emailAddressVerificationEmail.error'); } const emailAddressVerificationEmail = new EmailAddressVerificationEmail( email, link, ); return new EmailSender(emailAddressVerificationEmail).send(); } static async sendPasswordResetEmail( email: string, _type: 'register' | 'invitation' = 'register', host?: string, ) { let link: string; try { const token = await UsersDBApi.generatePasswordResetToken(email); link = frontendUrl(host, `/password-reset?token=${token}`); } catch (error) { logger.error('Failed to generate password reset token', error); throw new ValidationError('auth.passwordReset.error'); } const passwordResetEmail = new PasswordResetEmail(email, link); return new EmailSender(passwordResetEmail).send(); } static async sendInvitationEmail( email: string, temporaryPassword: string, host?: string, ) { const invitationEmail = new InvitationEmail(email, { loginUrl: frontendUrl(host, '/login'), temporaryPassword, }); return new EmailSender(invitationEmail).send(); } static isEmailConfigured(): boolean { return EmailSender.isConfigured; } static async verifyEmail(token: string, options: DbApiOptions = {}) { const user = await UsersDBApi.findByEmailVerificationToken(token, options); if (!user) { throw new ValidationError( 'auth.emailAddressVerificationEmail.invalidToken', ); } return UsersDBApi.markEmailVerified(user.id, options); } static async passwordUpdate( currentPassword: string, newPassword: string, options: DbApiOptions, ) { const currentUser = options.currentUser ?? null; if (!currentUser) { throw new ForbiddenError(); } const storedPassword = String(currentUser.password ?? ''); const currentPasswordMatch = await bcrypt.compare( currentPassword, storedPassword, ); if (!currentPasswordMatch) { throw new ValidationError('auth.wrongPassword'); } const newPasswordMatch = await bcrypt.compare(newPassword, storedPassword); if (newPasswordMatch) { throw new ValidationError('auth.passwordUpdate.samePassword'); } const hashedPassword = await bcrypt.hash( newPassword, config.bcrypt.saltRounds, ); return UsersDBApi.updatePassword(asId(currentUser.id), hashedPassword, options); } /** * Self-service profile update for the signed-in user: honorific, name, * phone, and email. Role/tenant/disabled are NOT self-editable (a boss changes * those via the users admin). Returns the refreshed profile. */ static async updateOwnProfile( data: { name_prefix?: unknown; firstName?: unknown; lastName?: unknown; phoneNumber?: unknown; email?: unknown; avatar?: unknown; }, options: DbApiOptions, ) { const currentUser = options.currentUser ?? null; if (!currentUser?.id) { throw new ForbiddenError(); } const updates: { name_prefix?: UserNamePrefix | null; firstName?: string; lastName?: string; phoneNumber?: string | null; email?: string; avatar?: FileInput | FileInput[] | null; } = {}; if (data?.name_prefix === null) { updates.name_prefix = null; } else if (typeof data?.name_prefix === 'string') { const prefix = USER_NAME_PREFIX_VALUES.find((p) => p === data.name_prefix); if (prefix) { updates.name_prefix = prefix; } } if (typeof data?.firstName === 'string') { updates.firstName = data.firstName.trim(); } if (typeof data?.lastName === 'string') { updates.lastName = data.lastName.trim(); } if (data?.phoneNumber === null) { updates.phoneNumber = null; } else if (typeof data?.phoneNumber === 'string') { updates.phoneNumber = data.phoneNumber.trim() || null; } if (typeof data?.email === 'string' && data.email.trim().length > 0) { updates.email = data.email.trim(); } // Avatar: a privateUrl string from the file upload becomes a new file // relation; `null` clears it. The download endpoint serves it by privateUrl, // so publicUrl mirrors it for local storage. if (data?.avatar === null) { updates.avatar = []; } else if (typeof data?.avatar === 'string' && data.avatar.length > 0) { const privateUrl = data.avatar; const name = privateUrl.split('/').pop() || 'avatar'; updates.avatar = [{ new: true, name, privateUrl, publicUrl: privateUrl }]; } await UsersDBApi.update(asId(currentUser.id), updates, false, options); return this.currentUserProfile(currentUser); } static async passwordReset( token: string, password: string, options: DbApiOptions = {}, ) { const user = await UsersDBApi.findByPasswordResetToken(token, options); if (!user) { throw new ValidationError('auth.passwordReset.invalidToken'); } const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds); return UsersDBApi.updatePassword(user.id, hashedPassword, options); } static async updateProfile(data: UpdateProfileData, currentUser: CurrentUser) { const transaction = await db.sequelize.transaction(); try { await UsersDBApi.update(asId(currentUser.id), data, false, { currentUser, transaction, }); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } } } export default Auth;