diff --git a/src/auth/application/controllers/oauth/swagger.ts b/src/auth/application/controllers/oauth/swagger.ts index 5c04d53..a14e345 100644 --- a/src/auth/application/controllers/oauth/swagger.ts +++ b/src/auth/application/controllers/oauth/swagger.ts @@ -19,6 +19,15 @@ import { ProvidersResponse, } from '../../dtos'; +export const ApiProviderParam = () => + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера', + enum: OAuthProvider, + required: true, + example: OAuthProvider.GOOGLE, + }); + export const OAuthLoginSwagger = () => applyDecorators( ApiOperation({ @@ -26,11 +35,7 @@ export const OAuthLoginSwagger = () => description: 'Перенаправляет пользователя на страницу аутентификации выбранного провайдера (google, github и т.д.).', }), - ApiParam({ - name: 'provider', - description: 'Название OAuth провайдера', - enum: OAuthProvider, - }), + ApiProviderParam(), ApiResponse({ status: 302, description: 'Успешное перенаправление на сторону провайдера.', @@ -42,14 +47,24 @@ export const OAuthCallbackSwagger = () => applyDecorators( ApiOperation({ summary: 'Callback для завершения OAuth авторизации', - description: - 'Обрабатывает ответ от провайдера, аутентифицирует пользователя, устанавливает refresh-токен в httpOnly cookie и возвращает результат.', - }), - ApiParam({ - name: 'provider', - description: 'Название OAuth провайдера', - enum: OAuthProvider, - }), + description: [ + 'Обрабатывает ответ от провайдера. Поддерживает два сценария:', + '', + '**1. Вход/Регистрация **', + '- Проверяет существование пользователя по email', + '- Если существует → выполняет вход', + '- Если не существует → создает нового пользователя', + '- Генерирует **одноразовый exchange-токен** (не access!)', + '- Перенаправляет на фронтенд с exchange-токеном: `?exchange_token=xxx&provider=google`', + '- Фронтенд обменивает exchange-токен на access/refresh через `/oauth/exchange`', + '', + '**2. Привязка провайдера (state содержит `connect_`)**', + '- Привязывает OAuth провайдера к существующему аккаунту', + '- Не генерирует токены', + '- Перенаправляет на фронтенд: `same-url?success=true&provider=google&message=connected`', + ].join('\n'), + }), + ApiProviderParam(), ApiResponse({ status: 302, description: 'Успешный вход. Перенаправление на фронтенд с параметрами авторизации.', @@ -99,7 +114,7 @@ export const ConnectOAuthProviderSwagger = () => ApiResponse({ status: 200, description: 'Провайдер успешно привязан к аккаунту.', - type: [ConnectProviderResponse.Output], + type: ConnectProviderResponse.Output, }), ApiBadRequest( 'Провайдер уже привязан к этому аккаунту или указан неподдерживаемый провайдер', diff --git a/src/auth/application/dtos/oauth.dto.ts b/src/auth/application/dtos/oauth.dto.ts index 2a90476..2eb5ed2 100644 --- a/src/auth/application/dtos/oauth.dto.ts +++ b/src/auth/application/dtos/oauth.dto.ts @@ -64,6 +64,9 @@ export const ExchangeSchema = z.object({ .min(32, 'Token must be at least 32 characters') .max(128, 'Token must not exceed 128 characters') .regex(/^[a-f0-9]+$/, 'Token must be hexadecimal string'), + provider: z + .string() + .describe('Название OAuth-провайдера (например, "google", "github", "facebook")'), }); export class ExchangeDto extends createZodDto(ExchangeSchema) {} diff --git a/src/auth/application/use-cases/oauth/connect-provider.use-case.ts b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts index c60d3b8..324cf9d 100644 --- a/src/auth/application/use-cases/oauth/connect-provider.use-case.ts +++ b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts @@ -23,7 +23,7 @@ export class ConnectProviderUseCase { private readonly STATE_KEY = (state: string) => `oauth:state:${state}`; async execute(provider: string, userId: string) { - await this.findUserQ.execute({ id: userId }); + await this.findUserQ.execute({ id: userId }, { throwIfNotFound: true }); await this.validateProviderNotConnected(userId, provider); await this.validateNoActiveSession(userId, provider); diff --git a/src/auth/application/use-cases/oauth/exchange.use-case.ts b/src/auth/application/use-cases/oauth/exchange.use-case.ts index ab762bd..98dcb51 100644 --- a/src/auth/application/use-cases/oauth/exchange.use-case.ts +++ b/src/auth/application/use-cases/oauth/exchange.use-case.ts @@ -24,6 +24,7 @@ export class ExchangeUseCase { async execute(dto: ExchangeDto, meta: DeviceMetadata) { const key = EXCHANGE_TOKEN_NAME(dto.token); + const rawData = await this.cacheService.getOne(key); if (!rawData) { @@ -36,15 +37,15 @@ export class ExchangeUseCase { ); } - const data = JSON.parse(rawData) as IOAuthExchangeData; + const data: IOAuthExchangeData = JSON.parse(rawData); await this.cacheService.removeOne(key); - if (!data.userId || !data.email) { + if (!data.userId || !data.email || data.provider !== dto.provider) { await this.cacheService.removeOne(key); throw new BaseException( { - message: 'Неверный формат данных авторизации', - code: 'EXCHANGE_DATA_CORRUPTED', + code: OAuthErrorCodes.DATA_CORRUPTION, + message: OAuthErrorMessages[OAuthErrorCodes.DATA_CORRUPTION], }, HttpStatus.INTERNAL_SERVER_ERROR, ); @@ -67,8 +68,8 @@ export class ExchangeUseCase { if (!result?.id) { throw new BaseException( { - message: 'Не удалось создать сессию', - code: 'SESSION_CREATION_FAILED', + code: OAuthErrorCodes.SESSION_CREATION_FAILED, + message: OAuthErrorMessages[OAuthErrorCodes.SESSION_CREATION_FAILED], }, HttpStatus.INTERNAL_SERVER_ERROR, ); @@ -90,8 +91,8 @@ export class ExchangeUseCase { throw new BaseException( { - message: 'Внутренняя ошибка сервера при создании сессии', - code: 'SESSION_CREATION_INTERNAL_ERROR', + code: OAuthErrorCodes.SESSION_CREATION_INTERNAL_ERROR, + message: OAuthErrorMessages[OAuthErrorCodes.SESSION_CREATION_INTERNAL_ERROR], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/auth/domain/errors/oauth.error.ts b/src/auth/domain/errors/oauth.error.ts index ad3c55f..d2c8957 100644 --- a/src/auth/domain/errors/oauth.error.ts +++ b/src/auth/domain/errors/oauth.error.ts @@ -15,8 +15,6 @@ export const OAuthErrorCodes = { DATA_CORRUPTION: 'OAUTH.DATA_CORRUPTION', SESSION_CREATION_FAILED: 'OAUTH.SESSION_CREATION_FAILED', SESSION_CREATION_INTERNAL_ERROR: 'OAUTH.SESSION_CREATION_INTERNAL_ERROR', - PROVIDER_CONNECT_FAILED: 'OAUTH.PROVIDER_CONNECT_FAILED', - PROVIDER_DISCONNECT_FAILED: 'OAUTH.PROVIDER_DISCONNECT_FAILED', } as const; export type OAuthErrorCode = (typeof OAuthErrorCodes)[keyof typeof OAuthErrorCodes]; @@ -40,6 +38,4 @@ export const OAuthErrorMessages: Record = { [OAuthErrorCodes.DATA_CORRUPTION]: 'Ошибка целостности данных', [OAuthErrorCodes.SESSION_CREATION_FAILED]: 'Не удалось создать сессию', [OAuthErrorCodes.SESSION_CREATION_INTERNAL_ERROR]: 'Внутренняя ошибка при создании сессии', - [OAuthErrorCodes.PROVIDER_CONNECT_FAILED]: 'Не удалось привязать провайдера', - [OAuthErrorCodes.PROVIDER_DISCONNECT_FAILED]: 'Не удалось отвязать провайдера', };