531 lines
15 KiB
TypeScript
531 lines
15 KiB
TypeScript
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<UserEntity>,
|
|
@InjectRepository(RefreshTokenEntity)
|
|
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
|
@InjectRepository(UserKeycloakGroupEntity)
|
|
private readonly userKeycloakGroupsRepository: Repository<UserKeycloakGroupEntity>,
|
|
@InjectRepository(UserImpersonationEntity)
|
|
private readonly userImpersonationsRepository: Repository<UserImpersonationEntity>,
|
|
@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<AuthTokenResponse> {
|
|
void loginDto;
|
|
throw new GoneException('Login is handled by SSO.');
|
|
}
|
|
|
|
startSsoLogin(): Promise<string> {
|
|
return this.oidcService.createAuthorizationUrl();
|
|
}
|
|
|
|
async completeSsoLogin(
|
|
code?: string,
|
|
state?: string,
|
|
): Promise<AuthTokenResponse> {
|
|
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<AuthTokenResponse> {
|
|
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<JwtTokenPayload> {
|
|
try {
|
|
const payload = this.jwtService.verify<JwtTokenPayload>(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<string> {
|
|
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<PublicUser> {
|
|
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<PublicUserSearchResult[]> {
|
|
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<PublicUser> {
|
|
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<PublicUser> {
|
|
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<UserEntity> {
|
|
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<UserEntity> {
|
|
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<void> {
|
|
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<string[]> {
|
|
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<AuthTokens> {
|
|
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<JwtTokenPayload>(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<PublicUser> {
|
|
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,
|
|
};
|
|
}
|
|
}
|