Skip to content

Commit dca67cf

Browse files
committed
fix: oauth intergration and refactor process flow
1 parent ac868bd commit dca67cf

19 files changed

Lines changed: 323 additions & 68 deletions

File tree

libs/bootstrap/src/setups/swagger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export async function setupSwagger(app: NestFastifyApplication, options: Swagger
5050
extraModels: [GlobalErrorResponse.Output],
5151
});
5252

53-
const customCss = await getCustomCSS();
53+
const customCss = await getCustomCSS().catch(() => '');
5454

5555
SwaggerModule.setup(path, app, cleanupOpenApiDoc(document), {
5656
jsonDocumentUrl: `${path}/s/json`,

src/auth/application/auth.facade.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Injectable } from '@nestjs/common';
22

33
import {
4+
ExchangeDto,
45
OAuthResponse,
56
PasswordResetConfirmDto,
67
ResendCodeDto,
@@ -25,6 +26,7 @@ import {
2526
GetConnectedProvidersQuery,
2627
GetEnabledProvidersQuery,
2728
ResendCodeUseCase,
29+
ExchangeUseCase,
2830
} from './use-cases';
2931

3032
import type { DeviceMetadata } from '../infrastructure/utils';
@@ -46,6 +48,7 @@ export class AuthFacade {
4648
private readonly getConnectedProvidersQuery: GetConnectedProvidersQuery,
4749
private readonly confirmResetPasswordUseCase: ConfirmResetPasswordUseCase,
4850
private readonly resendCodeUseCase: ResendCodeUseCase,
51+
private readonly exchangeTokenUC: ExchangeUseCase,
4952
) {}
5053

5154
public async signIn(dto: SignInDto, device: DeviceMetadata) {
@@ -84,6 +87,10 @@ export class AuthFacade {
8487
return this.confirmResetPasswordUseCase.execute(dto);
8588
}
8689

90+
public async exchangeToken(dto: ExchangeDto, device: DeviceMetadata) {
91+
return this.exchangeTokenUC.execute(dto, device);
92+
}
93+
8794
public async authenticateOAuth(dto: OAuthResponse, device: DeviceMetadata, state?: string) {
8895
return this.authenticateOAuthUseCase.execute(dto, device, state);
8996
}

src/auth/application/controller/oauth/controller.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import { getDeviceMeta } from '@core/auth/infrastructure/utils';
2-
import { Delete, Get, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
2+
import {
3+
Body,
4+
Delete,
5+
Get,
6+
HttpCode,
7+
Param,
8+
Post,
9+
Query,
10+
Req,
11+
Res,
12+
UseGuards,
13+
} from '@nestjs/common';
314
import { ConfigService } from '@nestjs/config';
415
import { ApiBaseController, GetUserId, SkipContract } from '@shared/decorators';
516
import { BearerAuthGuard, OAuthGuard } from '@shared/guards';
617

718
import { AuthFacade } from '../../auth.facade';
19+
import { ExchangeDto, type TOAuthResponse } from '../../dtos';
820

921
import {
1022
DisconnectOAuthProviderSwagger,
@@ -13,12 +25,12 @@ import {
1325
GetOAuthProvidersSwagger,
1426
OAuthCallbackSwagger,
1527
OAuthLoginSwagger,
28+
ExchangeSwagger,
1629
} from './swagger';
1730

18-
import type { TOAuthResponse } from '../../dtos';
1931
import type { FastifyReply, FastifyRequest } from 'fastify';
2032

21-
@ApiBaseController('auth/oauth', 'OAuth')
33+
@ApiBaseController('oauth', 'OAuth')
2234
export class OAuthController {
2335
private readonly isProduction: boolean = false;
2436
private readonly domain?: string | null = null;
@@ -66,14 +78,30 @@ export class OAuthController {
6678

6779
const baseUrl = `https://dev.${this.domain}`;
6880

69-
if (result.isSign && result.refresh) {
70-
this.setRefreshCookie(res, result.refresh, result.expiresAt);
81+
if (result.isSign) {
7182
res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302);
7283
} else {
7384
res.redirect(`${baseUrl}/user/profile?${result.query.toString()}`, 302);
7485
}
7586
}
7687

88+
@Post('exchange')
89+
@ExchangeSwagger()
90+
@HttpCode(200)
91+
async exchange(
92+
@Body() dto: ExchangeDto,
93+
@Res({ passthrough: true }) res: FastifyReply,
94+
@Req() req: FastifyRequest,
95+
) {
96+
const meta = getDeviceMeta(req);
97+
98+
const { expiresAt, refresh, ...result } = await this.facade.exchangeToken(dto, meta);
99+
100+
this.setRefreshCookie(res, refresh, expiresAt);
101+
102+
return result;
103+
}
104+
77105
@Get('providers')
78106
@GetOAuthProvidersSwagger()
79107
async getEnabledProviders() {

src/auth/application/controller/oauth/swagger.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { OAuthProvider } from '@core/auth/infrastructure/constants';
22
import { applyDecorators, SetMetadata } from '@nestjs/common';
3-
import { ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
3+
import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
44
import { ActionResponse } from '@shared/dtos';
55
import {
66
ApiBadRequest,
@@ -11,7 +11,13 @@ import {
1111
} from '@shared/error';
1212
import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors';
1313

14-
import { ConnectedProviders, ConnectProviderResponse, ProvidersResponse } from '../../dtos';
14+
import {
15+
ConnectedProviders,
16+
ConnectProviderResponse,
17+
ExchangeDto,
18+
ExchangeResponse,
19+
ProvidersResponse,
20+
} from '../../dtos';
1521

1622
export const OAuthLoginSwagger = () =>
1723
applyDecorators(
@@ -151,3 +157,23 @@ export const GetConnectedProvidersSwagger = () =>
151157

152158
SetMetadata(ZOD_RESPONSE_TOKEN, ConnectedProviders),
153159
);
160+
export const ExchangeSwagger = () =>
161+
applyDecorators(
162+
ApiOperation({
163+
summary: 'Обменять одноразовый токен на сессию',
164+
description:
165+
'Обменивает одноразовый exchange-токен, полученный после OAuth авторизации, на полноценную сессию с access и refresh токенами. Устанавливает refresh токен в httpOnly cookie.',
166+
}),
167+
ApiBody({
168+
type: ExchangeDto.Output,
169+
}),
170+
ApiResponse({
171+
status: 200,
172+
description: 'Токен успешно обменян. Возвращает access токен и данные пользователя.',
173+
type: ExchangeResponse.Output,
174+
}),
175+
ApiBadRequest('Неверный запрос. Токен отсутствует, истёк или имеет неверный формат.'),
176+
ApiUnauthorized(),
177+
ApiValidationError(),
178+
SetMetadata(ZOD_RESPONSE_TOKEN, ExchangeResponse),
179+
);

src/auth/application/dtos/oauth.dto.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,43 @@ export const ConnectProviderSchema = z.object({
5757
});
5858

5959
export class ConnectProviderResponse extends createZodDto(ConnectProviderSchema) {}
60+
61+
export const ExchangeSchema = z.object({
62+
token: z
63+
.string()
64+
.min(32, 'Token must be at least 32 characters')
65+
.max(128, 'Token must not exceed 128 characters')
66+
.regex(/^[a-f0-9]+$/, 'Token must be hexadecimal string'),
67+
});
68+
69+
export class ExchangeDto extends createZodDto(ExchangeSchema) {}
70+
71+
export interface IOAuthExchangeData {
72+
userId: string;
73+
isNewUser: boolean;
74+
email: string;
75+
provider: 'google' | 'yandex' | 'github' | 'vkontakte';
76+
ip: string;
77+
}
78+
79+
export const ExchangeResponseSchema = z.object({
80+
success: z.boolean().describe('Успешность операции'),
81+
message: z
82+
.string()
83+
.min(1, 'message не может быть пустым')
84+
.max(255, 'message не длиннее 255 символов')
85+
.describe('Сообщение для тоста'),
86+
access: z
87+
.string()
88+
.min(10, 'access токен слишком короткий')
89+
.max(500, 'access токен слишком длинный')
90+
.describe('JWT access токен'),
91+
isNewUser: z.boolean().describe('Новый пользователь?'),
92+
provider: z
93+
.enum(['google', 'yandex', 'github', 'vkontakte'], {
94+
message: 'provider должен быть: google, yandex, github или vkontakte',
95+
})
96+
.describe('OAuth провайдер'),
97+
});
98+
99+
export class ExchangeResponse extends createZodDto(ExchangeResponseSchema) {}

src/auth/application/use-cases/index.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AuthenticateOAuthUseCase } from './oauth/authenticate-oauth.use-case';
33
import { ConnectOAuthProviderUseCase } from './oauth/connect-oauth-provider.use-case';
44
import { ConnectProviderUseCase } from './oauth/connect-provider.use-case';
55
import { DisconnectProviderUseCase } from './oauth/disconnect-provider.use-case';
6+
import { ExchangeUseCase } from './oauth/exchange.use-case';
67
import { GetConnectedProvidersQuery } from './oauth/get-connected-providers.query';
78
import { GetEnabledProvidersQuery } from './oauth/get-enabled-providers.query';
89
import { OAuthOrchestratorUseCase } from './oauth/oauth-orchestrator.use-case';
@@ -37,24 +38,26 @@ export const AuthUseCases = [
3738
SignOutUseCase,
3839
SignUpUseCase,
3940
ResendCodeUseCase,
41+
ExchangeUseCase,
4042
];
4143

42-
export { ConfirmResetPasswordUseCase } from './confirm-reset-password.use-case';
43-
export { VerifyResetPasswordUseCase } from './verify-reset-password.use-case';
44-
export { GetConnectedProvidersQuery } from './oauth/get-connected-providers.query';
45-
export { DisconnectProviderUseCase } from './oauth/disconnect-provider.use-case';
46-
export { AuthenticateOAuthUseCase } from './oauth/authenticate-oauth.use-case';
47-
export { ConnectProviderUseCase } from './oauth/connect-provider.use-case';
48-
export { RefreshTokensUseCase } from './refresh-tokens.use-case';
49-
export { ResetPasswordUseCase } from './reset-password.use-case';
50-
export { SignUpVerifyUseCase } from './sign-up-verify.use-case';
51-
export { GetEnabledProvidersQuery } from './oauth/get-enabled-providers.query';
44+
export * from './confirm-reset-password.use-case';
45+
export * from './verify-reset-password.use-case';
46+
export * from './oauth/get-connected-providers.query';
47+
export * from './oauth/disconnect-provider.use-case';
48+
export * from './oauth/authenticate-oauth.use-case';
49+
export * from './oauth/connect-provider.use-case';
50+
export * from './refresh-tokens.use-case';
51+
export * from './reset-password.use-case';
52+
export * from './sign-up-verify.use-case';
53+
export * from './oauth/get-enabled-providers.query';
54+
export * from './oauth/exchange.use-case';
5255

53-
export { OAuthOrchestratorUseCase } from './oauth/oauth-orchestrator.use-case';
54-
export { ProcessOAuthLoginUseCase } from './oauth/process-oauth-login.use-case';
55-
export { ProcessOAuthRegistrationUseCase } from './oauth/process-oauth-registration.use-case';
56-
export { ConnectOAuthProviderUseCase } from './oauth/connect-oauth-provider.use-case';
57-
export { SignInUseCase } from './sign-in.use-case';
58-
export { SignOutUseCase } from './sign-out.use-case';
59-
export { SignUpUseCase } from './sign-up.use-case';
60-
export { ResendCodeUseCase } from './resend-code.use-case';
56+
export * from './oauth/oauth-orchestrator.use-case';
57+
export * from './oauth/process-oauth-login.use-case';
58+
export * from './oauth/process-oauth-registration.use-case';
59+
export * from './oauth/connect-oauth-provider.use-case';
60+
export * from './sign-in.use-case';
61+
export * from './sign-out.use-case';
62+
export * from './sign-up.use-case';
63+
export * from './resend-code.use-case';
Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { ISessionRepository } from '@core/auth/domain/repository';
2-
import { TokenService } from '@core/auth/infrastructure/security';
1+
import crypto from 'node:crypto';
2+
33
import { Inject, Injectable } from '@nestjs/common';
4-
import { createId } from '@paralleldrive/cuid2';
4+
import { CACHE_SERVICE } from '@shared/adapters/cache/constants';
5+
import { ICacheService } from '@shared/adapters/cache/ports';
6+
7+
import { EXCHANGE_TOKEN_NAME, EXCHANGE_TOKEN_TTL } from '../../../infrastructure/constants';
58

69
import { OAuthOrchestratorUseCase } from './oauth-orchestrator.use-case';
710

@@ -11,10 +14,9 @@ import type { DeviceMetadata } from '@core/auth/infrastructure/utils';
1114
@Injectable()
1215
export class AuthenticateOAuthUseCase {
1316
constructor(
17+
@Inject(CACHE_SERVICE)
18+
private readonly cacheService: ICacheService,
1419
private readonly orchestrator: OAuthOrchestratorUseCase,
15-
@Inject('ISessionRepository')
16-
private readonly sessionRepo: ISessionRepository,
17-
private readonly tokenService: TokenService,
1820
) {}
1921

2022
async execute(dto: OAuthResponse, meta: DeviceMetadata, state?: string) {
@@ -33,28 +35,26 @@ export class AuthenticateOAuthUseCase {
3335
expiresAt: null,
3436
};
3537
}
38+
const token = crypto.randomBytes(32).toString('hex');
3639

37-
const sessionId = createId();
38-
const { access, expiresAt, refresh } = await this.tokenService.generateTokens(
39-
user,
40-
sessionId,
41-
);
42-
43-
await this.sessionRepo.create({
44-
id: sessionId,
45-
...meta,
46-
expiresAt: expiresAt.toISOString(),
40+
const data = {
4741
userId: user.id,
48-
});
42+
isNewUser,
43+
email: user.email,
44+
provider: dto.provider,
45+
ip: meta.ip,
46+
};
47+
48+
await this.cacheService.setOne(
49+
EXCHANGE_TOKEN_NAME(token),
50+
JSON.stringify(data),
51+
EXCHANGE_TOKEN_TTL,
52+
);
4953

5054
const query = new URLSearchParams({
51-
success: 'true',
52-
message: isNewUser ? 'Регистрация успешна' : 'Вход успешен',
53-
access,
54-
provider: dto.provider,
55-
isNewUser: String(isNewUser),
55+
token,
5656
});
5757

58-
return { query, refresh, expiresAt, isSign: true };
58+
return { query, isSign: true };
5959
}
6060
}

0 commit comments

Comments
 (0)