Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ca68ad7
fix(auth): unify refreshTokenExpiresAt type to Unix timestamp
aquie00t May 4, 2026
414c66c
test(auth): add CheckUserUseCase and LoginUseCase unit tests
aquie00t May 4, 2026
396e5f0
refactor(persistence): extract P2002 handling into private helper in …
aquie00t May 4, 2026
0ed8306
test(auth): add RegisterUseCase unit tests
aquie00t May 4, 2026
3118bd0
refactor(auth): remove unnecessary TransactionPort from LogoutUseCase
aquie00t May 4, 2026
5dadae2
test(auth): add LogoutUseCase unit tests
aquie00t May 4, 2026
8fc1afb
fix(auth): persist refresh token in RecoverAccountUseCase
aquie00t May 4, 2026
2cbdbfc
test(auth): add RefreshUseCase unit tests
aquie00t May 4, 2026
628cccd
fix(auth): replace PasswordService with PasswordPort in ResetPassword…
aquie00t May 4, 2026
f6da0e7
test(auth): add ForgotPasswordUseCase unit tests
aquie00t May 4, 2026
e7f134e
feat(auth): make OTP expiry configurable via OTP_EXPIRY_MS env var
aquie00t May 4, 2026
a4d93e2
test(auth): add SendVerificationEmailUseCase unit tests
aquie00t May 4, 2026
7b5c672
fix(auth): timing-safe OTP comparison and atomic verify/reset operations
aquie00t May 4, 2026
2974da5
test: add buildVerificationToken factory and fix e2e global setup
aquie00t May 4, 2026
d3895b2
fix(di): use positional args in asFunction registrations (CLASSIC mode)
aquie00t May 4, 2026
081e7f6
test(auth): add VerifyEmailUseCase unit tests
aquie00t May 4, 2026
8ba6aee
test(auth): add RecoverAccountUseCase unit tests
aquie00t May 4, 2026
013f1fe
test(auth): add PurgeExpiredTokensUseCase unit tests
aquie00t May 4, 2026
0784beb
test(auth): add AuthMapper unit tests
aquie00t May 4, 2026
54651c2
test(auth): add ResetPasswordUseCase unit tests
aquie00t May 4, 2026
f2595e3
test(bookmark): add CreateBookmarkUseCase unit tests
aquie00t May 4, 2026
7a7a57f
test(bookmark): add GetBookmarksUseCase unit tests
aquie00t May 4, 2026
d979006
test(bookmark): add RemoveBookmarkUseCase unit tests
aquie00t May 4, 2026
01ca0ff
test(bookmark): add SaveCommentBookmarkUseCase and RemoveCommentBookm…
aquie00t May 4, 2026
c577173
test(comment): add unit tests for all comment use cases
aquie00t May 4, 2026
fa55841
fix(follow-user): add target user existence check; add unit tests
aquie00t May 4, 2026
c58787c
refactor(notification): fix typo in GetUserNotificatonUseCase → GetUs…
aquie00t May 5, 2026
8c54d9f
test(notification): add unit tests for GetUserNotificationUseCase
aquie00t May 5, 2026
9e8c0e6
test: add unit tests for notification, oauth, tag, translate and post…
aquie00t May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ ACCESS_TOKEN_SECRET_KEY=your_access_token_secret
ACCESS_TOKEN_EXPIRES_IN=900
REFRESH_TOKEN_EXPIRES_IN=1800

# OTP Configuration
OTP_EXPIRY_SECONDS=600

# Cookie Configuration
COOKIE_SECRET=your_cookie_secret

Expand Down
4 changes: 2 additions & 2 deletions src/core/ports/services/auth-token.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export interface TokenResult {
/** The opaque random refresh token string. */
refreshToken: string;

/** The date at which the refresh token expires. */
refreshTokenExpiresAt: Date;
/** The Unix timestamp (in seconds) at which the refresh token expires. */
refreshTokenExpiresAt: number;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/core/ports/services/crypto.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,13 @@ export interface CryptoPort {
* @returns A hex-encoded string of the specified byte length.
*/
generateRandomHex(bytes: number): string;

/**
* Compares two strings in constant time to prevent timing attacks.
*
* @param a - First string to compare.
* @param b - Second string to compare.
* @returns True if the strings are equal, false otherwise.
*/
timingSafeEqual(a: string, b: string): boolean;
}
3 changes: 3 additions & 0 deletions src/core/ports/services/transaction.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ICommentRepository } from "@core/ports/repositories/comment.reposi
import type { IPostLikeRepository } from "@core/ports/repositories/post-like.repository";
import type { INotificationRepository } from "@core/ports/repositories/notification.repository";
import type { IBookmarkRepository } from "../repositories/bookmark.repository";
import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository";

/**
* Provides transactional access to repositories within a single atomic operation.
Expand All @@ -29,6 +30,8 @@ export interface TransactionContext {
readonly notificationRepository: INotificationRepository;
/** */
readonly bookmarkRepository: IBookmarkRepository;
/** Repository for verification token operations within the transaction. */
readonly verificationTokenRepository: IVerificationTokenRepository;
}

/**
Expand Down
8 changes: 4 additions & 4 deletions src/core/use-cases/auth/auth.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ export class AuthMapper {
accessToken: string;
expiresAt: number;
refreshToken: string;
refreshTokenExpiresAt: Date;
refreshTokenExpiresAt: number;
}): {
accessToken: string;
expiresAt: number;
refreshToken: string;
refreshTokenExpiresAt: Date;
refreshTokenExpiresAt: number;
} {
return {
accessToken: tokens.accessToken,
Expand All @@ -61,14 +61,14 @@ export class AuthMapper {
accessToken: string;
expiresAt: number;
refreshToken: string;
refreshTokenExpiresAt: Date;
refreshTokenExpiresAt: number;
}): {
user: { id: string; username: string };
tokens: {
accessToken: string;
expiresAt: number;
refreshToken: string;
refreshTokenExpiresAt: Date;
refreshTokenExpiresAt: number;
};
} {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class ForgotPasswordUseCase {
private readonly verificationTokenRepository: IVerificationTokenRepository,
private readonly emailService: EmailPort,
private readonly cryptoService: CryptoPort,
private readonly otpExpirySeconds: number = 10 * 60,
) {}

/**
Expand Down Expand Up @@ -54,10 +55,7 @@ export class ForgotPasswordUseCase {

const otp = this.cryptoService.generateOtp(8);
const tokenHash = this.cryptoService.hashOtp(otp);
/**
* It can then be done from the .env file.
*/
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
const expiresAt = new Date(Date.now() + this.otpExpirySeconds * 1000);

await this.verificationTokenRepository.upsert({
userId: user.id,
Expand Down
4 changes: 2 additions & 2 deletions src/core/use-cases/auth/login/login.output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface LoginOutput {
expiresAt: number;
/** Long-lived token used to obtain new access tokens */
refreshToken: string;
/** Date indicating when the refresh token expires */
refreshTokenExpiresAt: Date;
/** Unix timestamp (in seconds) indicating when the refresh token expires */
refreshTokenExpiresAt: number;
};
}
2 changes: 1 addition & 1 deletion src/core/use-cases/auth/login/login.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class LoginUseCase {
userId: user.id,
deviceIp: input.deviceIp,
userAgent: input.userAgent,
expiresAt: refreshTokenExpiresAt,
expiresAt: new Date(refreshTokenExpiresAt * 1000),
});

return {
Expand Down
28 changes: 11 additions & 17 deletions src/core/use-cases/auth/logout/logout.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AuthTokenPort } from "@core/ports/services/auth-token.port";
import type { TransactionPort } from "@core/ports/services/transaction.port";
import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository";
import type { LogoutInput } from "./logout.input";

/**
Expand All @@ -12,11 +12,11 @@ export class LogoutUseCase {
/**
* Creates a new instance of LogoutUseCase.
*
* @param transactionService - Service for managing database transactions
* @param refreshTokenRepository - Repository for refresh token operations
* @param authTokenService - Service for token operations
*/
constructor(
private readonly transactionService: TransactionPort,
private readonly refreshTokenRepository: IRefreshTokenRepository,
private readonly authTokenService: AuthTokenPort,
) {}

Expand All @@ -28,26 +28,20 @@ export class LogoutUseCase {
*
* @remarks
* If no token is provided, the method returns silently.
* The operation is performed within a database transaction to ensure consistency.
* If the token is not found or already revoked, the method returns silently.
*/
async execute(input: LogoutInput): Promise<void> {
if (!input.token) {
return;
}

await this.transactionService.runInTransaction(async (ctx) => {
const tokenHash = this.authTokenService.hashRefreshSecret(
input.token,
);
const tokenHash = this.authTokenService.hashRefreshSecret(input.token);
const currentToken =
await this.refreshTokenRepository.findByTokenHash(tokenHash);

const currentToken =
await ctx.refreshTokenRepository.findByTokenHash(tokenHash);

if (currentToken && !currentToken.isRevoked) {
currentToken.revoke();

await ctx.refreshTokenRepository.update(currentToken);
}
});
if (currentToken && !currentToken.isRevoked) {
currentToken.revoke();
await this.refreshTokenRepository.update(currentToken);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,12 @@ export interface RecoverAccountInput {
* The token used to recover the account
*/
recoveryToken: string;
/**
* The IP address of the device making the recovery request
*/
deviceIp: string;
/**
* The user agent string of the device making the recovery request
*/
userAgent: string;
}
35 changes: 25 additions & 10 deletions src/core/use-cases/auth/recover-account/recover-account.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { IUserRepository } from "@core/ports/repositories/user.repository";
import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository";
import type {
AuthTokenPort,
RecoveryPayload,
} from "@core/ports/services/auth-token.port";
import { UnauthorizedError, BadRequestError } from "@core/errors";
import { UnauthorizedError } from "@core/errors";
import { type LoginOutput } from "../login/login.output";
import type { RecoverAccountInput } from "./recover-account.input";
import { AuthMapper } from "../auth.mapper";
Expand All @@ -19,10 +20,12 @@ export class RecoverAccountUseCase {
* Creates a new instance of RecoverAccountUseCase.
*
* @param userRepository - Repository for managing user data
* @param refreshTokenRepository - Repository for persisting refresh tokens
* @param authTokenService - Service for token operations
*/
constructor(
private readonly userRepository: IUserRepository,
private readonly refreshTokenRepository: IRefreshTokenRepository,
private readonly authTokenService: AuthTokenPort,
) {}

Expand Down Expand Up @@ -58,15 +61,27 @@ export class RecoverAccountUseCase {

const user = await this.userRepository.findById(userId);

if (!user) {
throw new BadRequestError("User not found.");
if (!user || !user.isDeleted()) {
throw new UnauthorizedError("Invalid or expired recovery token.");
}

await this.userRepository.restoreById(user.id);

const tokens = this.authTokenService.generate({
id: user.id,
username: user.username,
const { accessToken, expiresAt, refreshToken, refreshTokenExpiresAt } =
this.authTokenService.generate({
id: user.id,
username: user.username,
});

const refreshTokenHash =
this.authTokenService.hashRefreshSecret(refreshToken);

await this.refreshTokenRepository.create({
tokenHash: refreshTokenHash,
userId: user.id,
deviceIp: input.deviceIp,
userAgent: input.userAgent,
expiresAt: new Date(refreshTokenExpiresAt * 1000),
});

return {
Expand All @@ -76,10 +91,10 @@ export class RecoverAccountUseCase {
isEmailVerified: user.isEmailVerified,
},
tokens: AuthMapper.toTokenOutput({
accessToken: tokens.accessToken,
expiresAt: tokens.expiresAt,
refreshToken: tokens.refreshToken,
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
accessToken,
expiresAt,
refreshToken,
refreshTokenExpiresAt,
}),
};
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/use-cases/auth/refresh/refresh.output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { UserPayload } from "@core/ports/services/auth-token.port";
* @property {string} accessToken - The new access token for the user
* @property {number} expiresAt - The timestamp when the access token expires
* @property {string} refreshToken - The new refresh token for the user
* @property {Date} refreshTokenExpiresAt - The expiration date of the new refresh token
* @property {number} refreshTokenExpiresAt - The Unix timestamp (in seconds) of the new refresh token expiration
* @property {UserPayload} user - The payload containing user information included in the token
*/
export interface RefreshOutput {
Expand All @@ -23,7 +23,7 @@ export interface RefreshOutput {
/**
* The expiration date of the new refresh token, used by clients to know when they need to prompt the user to log in again
*/
refreshTokenExpiresAt: Date;
refreshTokenExpiresAt: number;
/**
* The payload containing user information included in the token, which can be used by clients to display user info without needing to decode the token themselves
*/
Expand Down
2 changes: 1 addition & 1 deletion src/core/use-cases/auth/refresh/refresh.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export class RefreshUseCase {
userId: user.id,
deviceIp: input.deviceIp,
userAgent: input.userAgent,
expiresAt: refreshTokenExpiresAt,
expiresAt: new Date(refreshTokenExpiresAt * 1000),
});

return {
Expand Down
19 changes: 14 additions & 5 deletions src/core/use-cases/auth/reset-password/reset-password.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { BadRequestError } from "@core/errors";
import type { IUserRepository } from "@core/ports/repositories/user.repository";
import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository";
import type { PasswordService } from "@infrastructure/security/password.service";
import type { PasswordPort } from "@core/ports/services/password.port";
import { TokenType } from "@core/domain/enums/token-type.enum";
import type { CryptoPort } from "@core/ports/services/crypto.port";
import type { TransactionPort } from "@core/ports/services/transaction.port";
import type { ResetPasswordInput } from "./reset-password.input";

/**
Expand All @@ -26,8 +27,9 @@ export class ResetPasswordUseCase {
constructor(
private readonly userRepository: IUserRepository,
private readonly verificationTokenRepository: IVerificationTokenRepository,
private readonly passwordService: PasswordService,
private readonly passwordService: PasswordPort,
private readonly cryptoService: CryptoPort,
private readonly transactionService: TransactionPort,
) {}

/**
Expand Down Expand Up @@ -67,7 +69,12 @@ export class ResetPasswordUseCase {

const hashedInputOtp = this.cryptoService.hashOtp(input.otp);

if (hashedInputOtp !== verificationToken.tokenHash) {
if (
!this.cryptoService.timingSafeEqual(
hashedInputOtp,
verificationToken.tokenHash,
)
) {
throw new BadRequestError(this.GENERIC_ERROR);
}

Expand All @@ -77,7 +84,9 @@ export class ResetPasswordUseCase {

user.hashPassword = newPasswordHash;

await this.userRepository.update(user);
await this.verificationTokenRepository.delete(verificationToken.id);
await this.transactionService.runInTransaction(async (ctx) => {
await ctx.userRepository.update(user);
await ctx.verificationTokenRepository.delete(verificationToken.id);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class SendVerificationEmailUseCase {
private readonly verificationTokenRepository: IVerificationTokenRepository,
private readonly emailService: EmailPort,
private readonly cryptoService: CryptoPort,
private readonly otpExpirySeconds: number = 10 * 60,
) {}

/**
Expand Down Expand Up @@ -55,7 +56,7 @@ export class SendVerificationEmailUseCase {

const hashedOtp = this.cryptoService.hashOtp(plainOtp);

const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
const expiresAt = new Date(Date.now() + this.otpExpirySeconds * 1000);

await this.verificationTokenRepository.upsert({
userId: user.id,
Expand Down
16 changes: 12 additions & 4 deletions src/core/use-cases/auth/verify-email/verify-email.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { IUserRepository } from "@core/ports/repositories/user.repository";
import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository";
import { TokenType } from "@core/domain/enums/token-type.enum";
import { type CryptoPort } from "@core/ports/services/crypto.port";
import type { TransactionPort } from "@core/ports/services/transaction.port";
import type { VerifyEmailInput } from "./verify-email.input";
/**
* Use case for verifying a user's email address using a one-time password (OTP)
Expand All @@ -20,6 +21,7 @@ export class VerifyEmailUseCase {
private readonly userRepository: IUserRepository,
private readonly verificationTokenRepository: IVerificationTokenRepository,
private readonly cryptoService: CryptoPort,
private readonly transactionService: TransactionPort,
) {}
/**
* Executes the email verification process for a user based on the provided input
Expand Down Expand Up @@ -56,14 +58,20 @@ export class VerifyEmailUseCase {
}

const hashedInputOtp = this.cryptoService.hashOtp(input.otp);
if (hashedInputOtp !== verificationToken.tokenHash) {
if (
!this.cryptoService.timingSafeEqual(
hashedInputOtp,
verificationToken.tokenHash,
)
) {
throw new BadRequestError("Invalid verification code.");
}

user.verifyEmail();

await this.userRepository.update(user);

await this.verificationTokenRepository.delete(verificationToken.id);
await this.transactionService.runInTransaction(async (ctx) => {
await ctx.userRepository.update(user);
await ctx.verificationTokenRepository.delete(verificationToken.id);
});
}
}
Loading
Loading