diff --git a/src/modules/user/application/dtos/OAuthCallbackDTO.ts b/src/modules/user/application/dtos/OAuthCallbackDTO.ts new file mode 100644 index 00000000..3b5a298b --- /dev/null +++ b/src/modules/user/application/dtos/OAuthCallbackDTO.ts @@ -0,0 +1,4 @@ +export interface OAuthCallbackDTO { + code: string; + state: string; +} diff --git a/src/modules/user/application/dtos/TokenDTO.ts b/src/modules/user/application/dtos/TokenDTO.ts new file mode 100644 index 00000000..366769d6 --- /dev/null +++ b/src/modules/user/application/dtos/TokenDTO.ts @@ -0,0 +1,11 @@ +export interface TokenPair { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export interface TokenPayload { + did: string; + iat: number; + exp: number; +} diff --git a/src/modules/user/application/dtos/UserDTO.ts b/src/modules/user/application/dtos/UserDTO.ts new file mode 100644 index 00000000..e04e0f5e --- /dev/null +++ b/src/modules/user/application/dtos/UserDTO.ts @@ -0,0 +1,6 @@ +export interface UserDTO { + did: string; + handle?: string; + linkedAt: Date; + lastLoginAt: Date; +} diff --git a/src/modules/user/application/mappers/UserMap.ts b/src/modules/user/application/mappers/UserMap.ts new file mode 100644 index 00000000..f203362c --- /dev/null +++ b/src/modules/user/application/mappers/UserMap.ts @@ -0,0 +1,45 @@ +import { User } from "../../domain/User"; +import { UserDTO } from "../dtos/UserDTO"; +import { DID } from "../../domain/value-objects/DID"; +import { Handle } from "../../domain/value-objects/Handle"; +import { UniqueEntityID } from "src/shared/domain/UniqueEntityID"; + +export class UserMap { + public static toDTO(user: User): UserDTO { + return { + did: user.did.value, + handle: user.handle?.value, + linkedAt: user.linkedAt, + lastLoginAt: user.lastLoginAt + }; + } + + public static toDomain(dto: UserDTO): User { + const didOrError = DID.create(dto.did); + + if (didOrError.isErr()) { + throw new Error(`Could not create DID: ${didOrError.error.message}`); + } + + let handle: Handle | undefined; + if (dto.handle) { + const handleOrError = Handle.create(dto.handle); + if (handleOrError.isOk()) { + handle = handleOrError.value; + } + } + + const userOrError = User.create({ + did: didOrError.value, + handle, + linkedAt: dto.linkedAt, + lastLoginAt: dto.lastLoginAt + }, new UniqueEntityID(dto.did)); + + if (userOrError.isErr()) { + throw new Error(`Could not create User: ${userOrError.error.message}`); + } + + return userOrError.value; + } +} diff --git a/src/modules/user/application/services/IOAuthProcessor.ts b/src/modules/user/application/services/IOAuthProcessor.ts new file mode 100644 index 00000000..4178ae59 --- /dev/null +++ b/src/modules/user/application/services/IOAuthProcessor.ts @@ -0,0 +1,12 @@ +import { Result } from "src/shared/core/Result"; +import { OAuthCallbackDTO } from "../dtos/OAuthCallbackDTO"; + +export interface AuthResult { + did: string; + handle?: string; +} + +export interface IOAuthProcessor { + generateAuthUrl(handle?: string): Promise>; + processCallback(params: OAuthCallbackDTO): Promise>; +} diff --git a/src/modules/user/application/services/ITokenService.ts b/src/modules/user/application/services/ITokenService.ts new file mode 100644 index 00000000..d7967253 --- /dev/null +++ b/src/modules/user/application/services/ITokenService.ts @@ -0,0 +1,9 @@ +import { Result } from "src/shared/core/Result"; +import { TokenPair } from "../dtos/TokenDTO"; + +export interface ITokenService { + generateToken(did: string): Promise>; + validateToken(token: string): Promise>; // Returns DID if valid + refreshToken(refreshToken: string): Promise>; + revokeToken(refreshToken: string): Promise>; +} diff --git a/src/modules/user/application/use-cases/CompleteOAuthSignInUseCase.ts b/src/modules/user/application/use-cases/CompleteOAuthSignInUseCase.ts new file mode 100644 index 00000000..b4fa610b --- /dev/null +++ b/src/modules/user/application/use-cases/CompleteOAuthSignInUseCase.ts @@ -0,0 +1,90 @@ +import { UseCase } from "src/shared/core/UseCase"; +import { Result, err, ok } from "src/shared/core/Result"; +import { AppError } from "src/shared/core/AppError"; +import { IOAuthProcessor } from "../services/IOAuthProcessor"; +import { ITokenService } from "../services/ITokenService"; +import { IUserRepository } from "../../domain/repositories/IUserRepository"; +import { OAuthCallbackDTO } from "../dtos/OAuthCallbackDTO"; +import { TokenPair } from "../dtos/TokenDTO"; +import { DID } from "../../domain/value-objects/DID"; +import { Handle } from "../../domain/value-objects/Handle"; +import { User } from "../../domain/User"; +import { CompleteOAuthSignInErrors } from "./errors/CompleteOAuthSignInErrors"; +import { IUserAuthenticationService } from "../../domain/services/IUserAuthenticationService"; + +export type CompleteOAuthSignInResponse = Result< + TokenPair, + | CompleteOAuthSignInErrors.InvalidCallbackParamsError + | CompleteOAuthSignInErrors.AuthenticationFailedError + | CompleteOAuthSignInErrors.TokenGenerationError + | AppError.UnexpectedError +>; + +export class CompleteOAuthSignInUseCase + implements UseCase> +{ + constructor( + private oauthProcessor: IOAuthProcessor, + private tokenService: ITokenService, + private userRepository: IUserRepository, + private userAuthService: IUserAuthenticationService + ) {} + + async execute(request: OAuthCallbackDTO): Promise { + try { + // Validate callback parameters + if (!request.code || !request.state) { + return err(new CompleteOAuthSignInErrors.InvalidCallbackParamsError()); + } + + // Process OAuth callback + const authResult = await this.oauthProcessor.processCallback(request); + + if (authResult.isErr()) { + return err(new CompleteOAuthSignInErrors.AuthenticationFailedError(authResult.error.message)); + } + + // Create DID value object + const didOrError = DID.create(authResult.value.did); + if (didOrError.isErr()) { + return err(new CompleteOAuthSignInErrors.AuthenticationFailedError(didOrError.error.message)); + } + const did = didOrError.value; + + // Create Handle value object if available + let handle: Handle | undefined; + if (authResult.value.handle) { + const handleOrError = Handle.create(authResult.value.handle); + if (handleOrError.isOk()) { + handle = handleOrError.value; + } + } + + // Validate user credentials through domain service + const authenticationResult = await this.userAuthService.validateUserCredentials(did, handle); + + if (authenticationResult.isErr()) { + return err(new CompleteOAuthSignInErrors.AuthenticationFailedError(authenticationResult.error.message)); + } + + const user = authenticationResult.value.user; + + // Record login + user.recordLogin(); + + // Save updated user + await this.userRepository.save(user); + + // Generate tokens + const tokenResult = await this.tokenService.generateToken(did.value); + + if (tokenResult.isErr()) { + return err(new CompleteOAuthSignInErrors.TokenGenerationError(tokenResult.error.message)); + } + + return ok(tokenResult.value); + } catch (error: any) { + return err(new AppError.UnexpectedError(error)); + } + } +} diff --git a/src/modules/user/application/use-cases/GetCurrentUserUseCase.ts b/src/modules/user/application/use-cases/GetCurrentUserUseCase.ts new file mode 100644 index 00000000..f6249f23 --- /dev/null +++ b/src/modules/user/application/use-cases/GetCurrentUserUseCase.ts @@ -0,0 +1,52 @@ +import { UseCase } from "src/shared/core/UseCase"; +import { Result, err, ok } from "src/shared/core/Result"; +import { AppError } from "src/shared/core/AppError"; +import { IUserRepository } from "../../domain/repositories/IUserRepository"; +import { UserDTO } from "../dtos/UserDTO"; +import { DID } from "../../domain/value-objects/DID"; +import { GetCurrentUserErrors } from "./errors/GetCurrentUserErrors"; +import { UserMap } from "../mappers/UserMap"; + +export interface GetCurrentUserDTO { + did: string; +} + +export type GetCurrentUserResponse = Result< + UserDTO, + GetCurrentUserErrors.UserNotFoundError | AppError.UnexpectedError +>; + +export class GetCurrentUserUseCase + implements UseCase> +{ + constructor(private userRepository: IUserRepository) {} + + async execute(request: GetCurrentUserDTO): Promise { + try { + const didOrError = DID.create(request.did); + + if (didOrError.isErr()) { + return err(new GetCurrentUserErrors.UserNotFoundError()); + } + + const userResult = await this.userRepository.findByDID(didOrError.value); + + if (userResult.isErr()) { + return err(new AppError.UnexpectedError(userResult.error)); + } + + const user = userResult.value; + + if (!user) { + return err(new GetCurrentUserErrors.UserNotFoundError()); + } + + // Map domain entity to DTO + const userDTO = UserMap.toDTO(user); + + return ok(userDTO); + } catch (error: any) { + return err(new AppError.UnexpectedError(error)); + } + } +} diff --git a/src/modules/user/application/use-cases/InitiateOAuthSignInUseCase.ts b/src/modules/user/application/use-cases/InitiateOAuthSignInUseCase.ts new file mode 100644 index 00000000..cdeb9b9c --- /dev/null +++ b/src/modules/user/application/use-cases/InitiateOAuthSignInUseCase.ts @@ -0,0 +1,44 @@ +import { UseCase } from "src/shared/core/UseCase"; +import { Result, err, ok } from "src/shared/core/Result"; +import { AppError } from "src/shared/core/AppError"; +import { IOAuthProcessor } from "../services/IOAuthProcessor"; +import { Handle } from "../../domain/value-objects/Handle"; +import { InitiateOAuthSignInErrors } from "./errors/InitiateOAuthSignInErrors"; + +export interface InitiateOAuthSignInDTO { + handle?: string; +} + +export type InitiateOAuthSignInResponse = Result< + { authUrl: string }, + InitiateOAuthSignInErrors.InvalidHandleError | AppError.UnexpectedError +>; + +export class InitiateOAuthSignInUseCase + implements UseCase> +{ + constructor(private oauthProcessor: IOAuthProcessor) {} + + async execute(request: InitiateOAuthSignInDTO): Promise { + try { + // Validate handle if provided + if (request.handle) { + const handleOrError = Handle.create(request.handle); + if (handleOrError.isErr()) { + return err(new InitiateOAuthSignInErrors.InvalidHandleError(handleOrError.error.message)); + } + } + + // Generate auth URL + const authUrlResult = await this.oauthProcessor.generateAuthUrl(request.handle); + + if (authUrlResult.isErr()) { + return err(new AppError.UnexpectedError(authUrlResult.error)); + } + + return ok({ authUrl: authUrlResult.value }); + } catch (error: any) { + return err(new AppError.UnexpectedError(error)); + } + } +} diff --git a/src/modules/user/application/use-cases/RefreshAccessTokenUseCase.ts b/src/modules/user/application/use-cases/RefreshAccessTokenUseCase.ts new file mode 100644 index 00000000..92fe1735 --- /dev/null +++ b/src/modules/user/application/use-cases/RefreshAccessTokenUseCase.ts @@ -0,0 +1,39 @@ +import { UseCase } from "src/shared/core/UseCase"; +import { Result, err, ok } from "src/shared/core/Result"; +import { AppError } from "src/shared/core/AppError"; +import { ITokenService } from "../services/ITokenService"; +import { TokenPair } from "../dtos/TokenDTO"; +import { RefreshAccessTokenErrors } from "./errors/RefreshAccessTokenErrors"; + +export interface RefreshAccessTokenDTO { + refreshToken: string; +} + +export type RefreshAccessTokenResponse = Result< + TokenPair, + RefreshAccessTokenErrors.InvalidRefreshTokenError | AppError.UnexpectedError +>; + +export class RefreshAccessTokenUseCase + implements UseCase> +{ + constructor(private tokenService: ITokenService) {} + + async execute(request: RefreshAccessTokenDTO): Promise { + try { + const tokenResult = await this.tokenService.refreshToken(request.refreshToken); + + if (tokenResult.isErr()) { + return err(new AppError.UnexpectedError(tokenResult.error)); + } + + if (!tokenResult.value) { + return err(new RefreshAccessTokenErrors.InvalidRefreshTokenError()); + } + + return ok(tokenResult.value); + } catch (error: any) { + return err(new AppError.UnexpectedError(error)); + } + } +} diff --git a/src/modules/user/application/use-cases/errors/CompleteOAuthSignInErrors.ts b/src/modules/user/application/use-cases/errors/CompleteOAuthSignInErrors.ts new file mode 100644 index 00000000..6dd243e5 --- /dev/null +++ b/src/modules/user/application/use-cases/errors/CompleteOAuthSignInErrors.ts @@ -0,0 +1,21 @@ +import { UseCaseError } from "src/shared/core/UseCaseError"; + +export namespace CompleteOAuthSignInErrors { + export class InvalidCallbackParamsError extends UseCaseError { + constructor() { + super("The OAuth callback parameters are invalid or missing"); + } + } + + export class AuthenticationFailedError extends UseCaseError { + constructor(message: string = "Authentication failed") { + super(message); + } + } + + export class TokenGenerationError extends UseCaseError { + constructor(message: string = "Failed to generate authentication tokens") { + super(message); + } + } +} diff --git a/src/modules/user/application/use-cases/errors/GetCurrentUserErrors.ts b/src/modules/user/application/use-cases/errors/GetCurrentUserErrors.ts new file mode 100644 index 00000000..52638c58 --- /dev/null +++ b/src/modules/user/application/use-cases/errors/GetCurrentUserErrors.ts @@ -0,0 +1,9 @@ +import { UseCaseError } from "src/shared/core/UseCaseError"; + +export namespace GetCurrentUserErrors { + export class UserNotFoundError extends UseCaseError { + constructor() { + super("User not found"); + } + } +} diff --git a/src/modules/user/application/use-cases/errors/InitiateOAuthSignInErrors.ts b/src/modules/user/application/use-cases/errors/InitiateOAuthSignInErrors.ts new file mode 100644 index 00000000..b36eb185 --- /dev/null +++ b/src/modules/user/application/use-cases/errors/InitiateOAuthSignInErrors.ts @@ -0,0 +1,9 @@ +import { UseCaseError } from "src/shared/core/UseCaseError"; + +export namespace InitiateOAuthSignInErrors { + export class InvalidHandleError extends UseCaseError { + constructor(message: string) { + super(`Invalid handle format: ${message}`); + } + } +} diff --git a/src/modules/user/application/use-cases/errors/RefreshAccessTokenErrors.ts b/src/modules/user/application/use-cases/errors/RefreshAccessTokenErrors.ts new file mode 100644 index 00000000..3f5dea49 --- /dev/null +++ b/src/modules/user/application/use-cases/errors/RefreshAccessTokenErrors.ts @@ -0,0 +1,9 @@ +import { UseCaseError } from "src/shared/core/UseCaseError"; + +export namespace RefreshAccessTokenErrors { + export class InvalidRefreshTokenError extends UseCaseError { + constructor() { + super("Invalid or expired refresh token"); + } + } +} diff --git a/src/modules/user/application/use-cases/index.ts b/src/modules/user/application/use-cases/index.ts new file mode 100644 index 00000000..51b2a844 --- /dev/null +++ b/src/modules/user/application/use-cases/index.ts @@ -0,0 +1,4 @@ +export * from './InitiateOAuthSignInUseCase'; +export * from './CompleteOAuthSignInUseCase'; +export * from './GetCurrentUserUseCase'; +export * from './RefreshAccessTokenUseCase';