Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 29 additions & 14 deletions src/auth/application/controllers/oauth/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,23 @@ import {
ProvidersResponse,
} from '../../dtos';

export const ApiProviderParam = () =>
ApiParam({
name: 'provider',
description: 'Название OAuth провайдера',
enum: OAuthProvider,
required: true,
example: OAuthProvider.GOOGLE,
});

export const OAuthLoginSwagger = () =>
applyDecorators(
ApiOperation({
summary: 'Инициализация OAuth авторизации',
description:
'Перенаправляет пользователя на страницу аутентификации выбранного провайдера (google, github и т.д.).',
}),
ApiParam({
name: 'provider',
description: 'Название OAuth провайдера',
enum: OAuthProvider,
}),
ApiProviderParam(),
ApiResponse({
status: 302,
description: 'Успешное перенаправление на сторону провайдера.',
Expand All @@ -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: 'Успешный вход. Перенаправление на фронтенд с параметрами авторизации.',
Expand Down Expand Up @@ -99,7 +114,7 @@ export const ConnectOAuthProviderSwagger = () =>
ApiResponse({
status: 200,
description: 'Провайдер успешно привязан к аккаунту.',
type: [ConnectProviderResponse.Output],
type: ConnectProviderResponse.Output,
}),
ApiBadRequest(
'Провайдер уже привязан к этому аккаунту или указан неподдерживаемый провайдер',
Expand Down
3 changes: 3 additions & 0 deletions src/auth/application/dtos/oauth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
17 changes: 9 additions & 8 deletions src/auth/application/use-cases/oauth/exchange.use-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
);
Expand All @@ -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,
);
Expand All @@ -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,
);
Expand Down
4 changes: 0 additions & 4 deletions src/auth/domain/errors/oauth.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -40,6 +38,4 @@ export const OAuthErrorMessages: Record<OAuthErrorCode, string> = {
[OAuthErrorCodes.DATA_CORRUPTION]: 'Ошибка целостности данных',
[OAuthErrorCodes.SESSION_CREATION_FAILED]: 'Не удалось создать сессию',
[OAuthErrorCodes.SESSION_CREATION_INTERNAL_ERROR]: 'Внутренняя ошибка при создании сессии',
[OAuthErrorCodes.PROVIDER_CONNECT_FAILED]: 'Не удалось привязать провайдера',
[OAuthErrorCodes.PROVIDER_DISCONNECT_FAILED]: 'Не удалось отвязать провайдера',
};
Loading