import { BadRequestException, GoneException, Injectable, Optional, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; import { Like, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { ResendVerificationDto } from './dto/resend-verification.dto'; import { AuthTokenResponse, AuthTokens, JwtTokenPayload, PublicUser, PublicUserSearchResult, } from './auth.types'; import type { TaskDigestPreference } from '../tasks/task-digest.types'; import { OidcProfile, OidcService } from './oidc.service'; import { RefreshTokenEntity } from './refresh-token.entity'; import { UserKeycloakGroupEntity } from './user-keycloak-group.entity'; import { UserImpersonationEntity } from './user-impersonation.entity'; import { UserEntity } from './user.entity'; @Injectable() export class AuthService { private readonly accessTokenExpiresIn = '7d'; private readonly refreshTokenExpiresIn = '30d'; private readonly accessTokenSecret = process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret'; private readonly refreshTokenSecret = process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret'; constructor( private readonly jwtService: JwtService, private readonly oidcService: OidcService, @InjectRepository(UserEntity) private readonly usersRepository: Repository, @InjectRepository(RefreshTokenEntity) private readonly refreshTokensRepository: Repository, @InjectRepository(UserKeycloakGroupEntity) private readonly userKeycloakGroupsRepository: Repository, @InjectRepository(UserImpersonationEntity) private readonly userImpersonationsRepository: Repository, @Optional() private readonly auditLogService?: AuditLogService, ) {} async register( registerDto: RegisterDto, ): Promise<{ message: string; user: PublicUser }> { void registerDto; throw new GoneException('Registration is handled by the SSO provider.'); } async verifyEmail( token?: string, ): Promise<{ message: string; user: PublicUser }> { void token; throw new GoneException('Email verification is no longer required.'); } async resendVerificationEmail( resendVerificationDto: ResendVerificationDto, ): Promise<{ message: string }> { void resendVerificationDto; throw new GoneException('Email verification is no longer required.'); } async login(loginDto: LoginDto): Promise { void loginDto; throw new GoneException('Login is handled by SSO.'); } startSsoLogin(): Promise { return this.oidcService.createAuthorizationUrl(); } async completeSsoLogin( code?: string, state?: string, ): Promise { const profile = await this.oidcService.exchangeCallback(code, state); const existingUser = (await this.usersRepository.findOne({ where: { oidcSubject: profile.subject }, })) ?? (await this.usersRepository.findOne({ where: { email: this.normalizeEmail(profile.email) }, })); const user = await this.syncOidcUser(profile, existingUser); await this.syncKeycloakGroups(user.id, profile.groups); const response = { ...(await this.createAuthTokens(user)), user: await this.toPublicUserWithGroups( await this.resolveEffectiveUser(user), ), }; await this.auditLogService?.record({ actorUserId: user.id, actorEmail: user.email, action: 'user.login_succeeded', entityType: 'user', entityId: user.id, metadata: { directory: 'oidc', oidcSubject: user.oidcSubject }, }); return response; } async refresh( refreshTokenDto: RefreshTokenDto = {}, ): Promise { const refreshToken = this.requireRefreshToken(refreshTokenDto.refreshToken); const payload = this.verifyRefreshToken(refreshToken); const tokenRecord = await this.refreshTokensRepository.findOne({ where: { jti: payload.jti }, }); if ( !tokenRecord || tokenRecord.userId !== payload.sub || tokenRecord.expiresAt.getTime() <= Date.now() || !this.tokenMatches(refreshToken, tokenRecord.tokenHash) ) { throw new UnauthorizedException('Refresh token is invalid.'); } const user = await this.usersRepository.findOne({ where: { id: payload.sub }, }); if (!user) { throw new UnauthorizedException('Refresh token is invalid.'); } await this.refreshTokensRepository.delete({ jti: payload.jti }); const response = { ...(await this.createAuthTokens(user)), user: await this.toPublicUserWithGroups( await this.resolveEffectiveUser(user), ), }; await this.auditLogService?.record({ actorUserId: user.id, actorEmail: user.email, action: 'user.token_refreshed', entityType: 'user', entityId: user.id, }); return response; } async verifyAccessToken(accessToken: string): Promise { try { const payload = this.jwtService.verify(accessToken, { secret: this.accessTokenSecret, }); if (payload.type !== 'access') { throw new UnauthorizedException('Access token is invalid.'); } const user = await this.usersRepository.findOne({ where: { id: payload.sub }, }); if (!user) { throw new UnauthorizedException('Access token is invalid.'); } const effectiveUser = await this.resolveEffectiveUser(user); if (effectiveUser.id === user.id) { return payload; } return { ...payload, sub: effectiveUser.id, email: effectiveUser.email, impersonatorSub: user.id, impersonatorEmail: user.email, }; } catch { throw new UnauthorizedException('Access token is invalid.'); } } async getUserDisplayName(userId: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, }); if (!user) { throw new UnauthorizedException('Authenticated user is required.'); } return user.name || user.email; } async getPublicUser(userId: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, }); if (!user) { throw new UnauthorizedException('Authenticated user is required.'); } return this.toPublicUserWithGroups(user); } async searchUsers( actorUserId: string, query?: string, ): Promise { const normalizedQuery = query?.trim(); if (!normalizedQuery || normalizedQuery.length < 2) { return []; } const pattern = `%${normalizedQuery}%`; const users = await this.usersRepository.find({ where: [{ email: Like(pattern) }, { name: Like(pattern) }], order: { email: 'ASC' }, take: 10, }); return users .filter((user) => user.id !== actorUserId) .filter( (user, index, allUsers) => allUsers.findIndex((existingUser) => existingUser.id === user.id) === index, ) .slice(0, 10) .map((user) => ({ id: user.id, email: user.email, name: user.name ?? undefined, })); } async updateOnboardingCompleted( userId: string, completed: boolean, ): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, }); if (!user) { throw new UnauthorizedException('Authenticated user is required.'); } user.onboardingCompleted = completed; const savedUser = await this.usersRepository.save(user); await this.auditLogService?.record({ actorUserId: savedUser.id, actorEmail: savedUser.email, action: 'user.onboarding_updated', entityType: 'user', entityId: savedUser.id, metadata: { completed }, }); return this.toPublicUserWithGroups(savedUser); } async updateTaskDigestPreference( userId: string, preference: unknown, ): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, }); if (!user) { throw new UnauthorizedException('Authenticated user is required.'); } user.taskDigestPreference = this.normalizeTaskDigestPreference(preference); const savedUser = await this.usersRepository.save(user); await this.auditLogService?.record({ actorUserId: savedUser.id, actorEmail: savedUser.email, action: 'user.task_digest_updated', entityType: 'user', entityId: savedUser.id, metadata: { taskDigestPreference: savedUser.taskDigestPreference }, }); return this.toPublicUserWithGroups(savedUser); } private normalizeEmail(email?: string): string { const normalizedEmail = email?.trim().toLowerCase(); if ( !normalizedEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail) ) { throw new BadRequestException('A valid email is required.'); } return normalizedEmail; } private normalizeName(name?: string): string | undefined { const normalizedName = name?.trim(); return normalizedName || undefined; } private normalizeTaskDigestPreference(value: unknown): TaskDigestPreference { if (value === 'none' || value === 'morning' || value === 'both') { return value; } throw new BadRequestException('Task digest preference is invalid.'); } private async syncOidcUser( profile: OidcProfile, existingUser?: UserEntity | null, ): Promise { const user = existingUser ?? this.usersRepository.create({ id: randomUUID(), onboardingCompleted: false, taskDigestPreference: 'both', }); user.email = this.normalizeEmail(profile.email); user.name = this.normalizeName(profile.name); user.oidcSubject = profile.subject; user.onboardingCompleted = user.onboardingCompleted === true; user.taskDigestPreference = user.taskDigestPreference ?? 'both'; return this.usersRepository.save(user); } private async resolveEffectiveUser(user: UserEntity): Promise { const now = Date.now(); const impersonations = await this.userImpersonationsRepository.find({ where: { impersonatorUserId: user.id, enabled: true }, order: { createdAt: 'DESC' }, }); const activeImpersonation = impersonations.find( (impersonation) => !impersonation.expiresAt || impersonation.expiresAt.getTime() > now, ); if (!activeImpersonation) { return user; } const targetUser = await this.usersRepository.findOne({ where: { id: activeImpersonation.targetUserId }, }); if (!targetUser) { throw new UnauthorizedException('Impersonated user does not exist.'); } return targetUser; } private async syncKeycloakGroups( userId: string, groups: string[], ): Promise { const normalizedGroups = this.normalizeGroupPaths(groups); await this.userKeycloakGroupsRepository.delete({ userId }); if (!normalizedGroups.length) { return; } await this.userKeycloakGroupsRepository.save( normalizedGroups.map((groupPath) => this.userKeycloakGroupsRepository.create({ id: randomUUID(), userId, groupPath, groupName: this.groupNameFromPath(groupPath), }), ), ); } private normalizeGroupPaths(groups: string[]): string[] { return [...new Set(groups)] .map((group) => group.trim()) .filter(Boolean) .sort((left, right) => left.localeCompare(right)); } private groupNameFromPath(groupPath: string): string { return groupPath.split('/').filter(Boolean).at(-1) ?? groupPath; } private async getUserGroupPaths(userId: string): Promise { const groups = await this.userKeycloakGroupsRepository.find({ where: { userId }, }); return groups .map((group) => group.groupPath) .sort((left, right) => left.localeCompare(right)); } private secretMatches(secret: string, storedSecretHash: string): boolean { const [salt, storedHash] = storedSecretHash.split(':'); if (!salt || !storedHash) { return false; } const attemptedHash = scryptSync(secret, salt, 64); const storedHashBuffer = Buffer.from(storedHash, 'hex'); return ( storedHashBuffer.length === attemptedHash.length && timingSafeEqual(storedHashBuffer, attemptedHash) ); } private async createAuthTokens(user: UserEntity): Promise { const refreshTokenJti = randomUUID(); const accessTokenPayload: JwtTokenPayload = { sub: user.id, email: user.email, type: 'access', }; const refreshTokenPayload: JwtTokenPayload = { sub: user.id, email: user.email, type: 'refresh', jti: refreshTokenJti, }; const accessToken = this.jwtService.sign(accessTokenPayload, { expiresIn: this.accessTokenExpiresIn, secret: this.accessTokenSecret, }); const refreshToken = this.jwtService.sign(refreshTokenPayload, { expiresIn: this.refreshTokenExpiresIn, secret: this.refreshTokenSecret, }); await this.refreshTokensRepository.save( this.refreshTokensRepository.create({ jti: refreshTokenJti, userId: user.id, tokenHash: this.hashToken(refreshToken), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }), ); return { accessToken, refreshToken }; } private verifyRefreshToken(refreshToken: string): JwtTokenPayload & { jti: string; } { try { const payload = this.jwtService.verify(refreshToken, { secret: this.refreshTokenSecret, }); if (payload.type !== 'refresh' || !payload.jti) { throw new UnauthorizedException('Refresh token is invalid.'); } return { ...payload, jti: payload.jti }; } catch { throw new UnauthorizedException('Refresh token is invalid.'); } } private requireRefreshToken(refreshToken?: string): string { if (!refreshToken) { throw new BadRequestException('Refresh token is required.'); } return refreshToken; } private hashToken(token: string): string { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(token, salt, 64).toString('hex'); return `${salt}:${hash}`; } private tokenMatches(token: string, tokenHash: string): boolean { return this.secretMatches(token, tokenHash); } private async toPublicUserWithGroups(user: UserEntity): Promise { return this.toPublicUser(user, await this.getUserGroupPaths(user.id)); } private toPublicUser(user: UserEntity, groups: string[] = []): PublicUser { return { id: user.id, email: user.email, name: user.name ?? undefined, onboardingCompleted: user.onboardingCompleted === true, taskDigestPreference: user.taskDigestPreference ?? 'both', groups, }; } }