Skip to content

Commit dfe716b

Browse files
authored
hotfix(auth): some edits
* feat(auth): implement token exchange endpoint and related functionality * feat(category): getCategories endpoint * fix(user): return it back * fix(db): return it back
1 parent d1bec0d commit dfe716b

File tree

11 files changed

+298
-34
lines changed

11 files changed

+298
-34
lines changed

src/auth/auth.controller.ts

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { VerifyUpdateEmailDto } from './dto/verify-update-email.dto';
3030
import { MobileGoogleAuthDto } from './dto/mobile-google-auth.dto';
3131
import { MobileGitHubAuthDto } from './dto/mobile-github-auth.dto';
3232
import { ForgetPasswordDto } from './dto/forget-password.dto';
33+
import { ExchangeTokenDto } from './dto/exchange-token.dto';
3334
import {
3435
ApiBearerAuth,
3536
ApiBody,
@@ -63,6 +64,7 @@ import {
6364
change_password_swagger,
6465
check_identifier_swagger,
6566
confirm_password_swagger,
67+
exchange_token_swagger,
6668
facebook_callback_swagger,
6769
facebook_oauth_swagger,
6870
forget_password_swagger,
@@ -281,6 +283,38 @@ export class AuthController {
281283
return { access_token };
282284
}
283285

286+
@ApiOperation(exchange_token_swagger.operation)
287+
@ApiBody({ type: ExchangeTokenDto })
288+
@ApiOkResponse(exchange_token_swagger.responses.completion_success)
289+
@ApiUnauthorizedErrorResponse(ERROR_MESSAGES.INVALID_OR_EXPIRED_TOKEN)
290+
@ResponseMessage(SUCCESS_MESSAGES.TOKEN_EXCHANGE_SUCCESS)
291+
@Post('exchange-token')
292+
async exchangeToken(@Body() body: ExchangeTokenDto, @Res() res: Response) {
293+
const { exchange_token } = body;
294+
const payload = await this.auth_service.validateExchangeToken(exchange_token);
295+
296+
if (payload.type === 'auth') {
297+
if (!payload.user_id) {
298+
throw new BadRequestException(ERROR_MESSAGES.INVALID_OR_EXPIRED_TOKEN);
299+
}
300+
301+
const { access_token, refresh_token } = await this.auth_service.generateTokens(
302+
payload.user_id
303+
);
304+
this.httpOnlyRefreshToken(res, refresh_token);
305+
306+
return res.json({
307+
type: 'auth',
308+
access_token,
309+
});
310+
} else {
311+
return res.json({
312+
type: 'completion',
313+
session_token: payload.session_token,
314+
});
315+
}
316+
}
317+
284318
@ApiOperation(captcha_swagger.operation)
285319
@ApiResponse(captcha_swagger.responses.success)
286320
@ResponseMessage('ReCAPTCHA site key retrieved successfully')
@@ -403,8 +437,13 @@ export class AuthController {
403437
// if the user doesn't have a record for that email in DB, we will need to redirect the user to complete his data
404438
if (req.user?.needs_completion) {
405439
const session_token = await this.auth_service.createOAuthSession(req.user.user);
440+
const exchange_token = await this.auth_service.createExchangeToken({
441+
session_token,
442+
type: 'completion',
443+
});
444+
406445
return res.redirect(
407-
`${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/oauth-complete?session=${encodeURIComponent(session_token)}&provider=google`
446+
`${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/oauth-complete?exchange_token=${encodeURIComponent(exchange_token)}&provider=google`
408447
);
409448
}
410449

@@ -417,15 +456,14 @@ export class AuthController {
417456
}
418457

419458
// Normal OAuth flow for existing users
420-
const { access_token, refresh_token } = await this.auth_service.generateTokens(
421-
req.user.id
422-
);
423-
424-
// Set refresh token in HTTP-only cookie
425-
this.httpOnlyRefreshToken(res, refresh_token);
426-
427-
// Redirect to frontend with access token
428-
const frontend_url = `${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/success?token=${encodeURIComponent(access_token)}`;
459+
// Create secure exchange token with user_id (tokens will be generated on exchange)
460+
const exchange_token = await this.auth_service.createExchangeToken({
461+
user_id: req.user.id,
462+
type: 'auth',
463+
});
464+
465+
// Redirect to frontend with exchange token
466+
const frontend_url = `${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/success?exchange_token=${encodeURIComponent(exchange_token)}&provider=google`;
429467
return res.redirect(frontend_url);
430468
} catch (error) {
431469
console.log('Google callback error:', error);
@@ -456,8 +494,13 @@ export class AuthController {
456494
// if the user doesn't have a record for that email in DB, we will need to redirect the user to complete his data
457495
if (req.user?.needs_completion) {
458496
const session_token = await this.auth_service.createOAuthSession(req.user.user);
497+
const exchange_token = await this.auth_service.createExchangeToken({
498+
session_token,
499+
type: 'completion',
500+
});
501+
459502
return res.redirect(
460-
`${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/oauth-complete?session=${encodeURIComponent(session_token)}&provider=facebook`
503+
`${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/oauth-complete?exchange_token=${encodeURIComponent(exchange_token)}&provider=facebook`
461504
);
462505
}
463506

@@ -470,15 +513,13 @@ export class AuthController {
470513
}
471514

472515
// Normal OAuth flow for existing users
473-
const { access_token, refresh_token } = await this.auth_service.generateTokens(
474-
req.user.id
475-
);
516+
const exchange_token = await this.auth_service.createExchangeToken({
517+
user_id: req.user.id,
518+
type: 'auth',
519+
});
476520

477-
// Set refresh token in HTTP-only cookie
478-
this.httpOnlyRefreshToken(res, refresh_token);
479-
480-
// Redirect to frontend with access token
481-
const frontend_url = `${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/success?token=${encodeURIComponent(access_token)}`;
521+
// Redirect to frontend with exchange token
522+
const frontend_url = `${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/success?exchange_token=${encodeURIComponent(exchange_token)}&provider=facebook`;
482523
return res.redirect(frontend_url);
483524
} catch (error) {
484525
console.log('Facebook callback error:', error);
@@ -549,8 +590,13 @@ export class AuthController {
549590
// if the user doesn't have a record for that email in DB, we will need to redirect the user to complete his data
550591
if (req.user?.needs_completion) {
551592
const session_token = await this.auth_service.createOAuthSession(req.user.user);
593+
const exchange_token = await this.auth_service.createExchangeToken({
594+
session_token,
595+
type: 'completion',
596+
});
597+
552598
return res.redirect(
553-
`${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/oauth-complete?session=${encodeURIComponent(session_token)}&provider=github`
599+
`${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/oauth-complete?exchange_token=${encodeURIComponent(exchange_token)}&provider=github`
554600
);
555601
}
556602

@@ -563,15 +609,13 @@ export class AuthController {
563609
}
564610

565611
// Normal OAuth flow for existing users
566-
const { access_token, refresh_token } = await this.auth_service.generateTokens(
567-
req.user.id
568-
);
569-
570-
// Set refresh token in HTTP-only cookie
571-
this.httpOnlyRefreshToken(res, refresh_token);
612+
const exchange_token = await this.auth_service.createExchangeToken({
613+
user_id: req.user.id,
614+
type: 'auth',
615+
});
572616

573-
// Redirect to frontend with access token
574-
const frontend_url = `${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/success?token=${encodeURIComponent(access_token)}`;
617+
// Redirect to frontend with exchange token
618+
const frontend_url = `${process.env.FRONTEND_URL || 'http://localhost:3001'}/auth/success?exchange_token=${encodeURIComponent(exchange_token)}&provider=github`;
575619
return res.redirect(frontend_url);
576620
} catch (error) {
577621
console.log('Github callback error:', error);

src/auth/auth.service.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { ConfigService } from '@nestjs/config';
3131
import { instanceToPlain } from 'class-transformer';
3232
import { ERROR_MESSAGES } from 'src/constants/swagger-messages';
3333
import {
34+
OAUTH_EXCHANGE_TOKEN_KEY,
35+
OAUTH_EXCHANGE_TOKEN_OBJECT,
3436
OAUTH_SESSION_KEY,
3537
OAUTH_SESSION_OBJECT,
3638
OTP_KEY,
@@ -1022,6 +1024,53 @@ export class AuthService {
10221024
};
10231025
}
10241026

1027+
async createExchangeToken(payload: {
1028+
user_id?: string;
1029+
session_token?: string;
1030+
type: 'auth' | 'completion';
1031+
}): Promise<string> {
1032+
const token_id = crypto.randomUUID();
1033+
const exchange_token = this.jwt_service.sign(
1034+
{ token_id, type: payload.type },
1035+
{
1036+
secret:
1037+
process.env.OAUTH_EXCHANGE_TOKEN_SECRET ?? 'fallback-exchange-secret-change-me',
1038+
expiresIn: '5m',
1039+
}
1040+
);
1041+
1042+
const redis_object = OAUTH_EXCHANGE_TOKEN_OBJECT(token_id, payload);
1043+
await this.redis_service.set(redis_object.key, redis_object.value, redis_object.ttl);
1044+
1045+
return exchange_token;
1046+
}
1047+
1048+
async validateExchangeToken(exchange_token: string): Promise<{
1049+
user_id?: string;
1050+
session_token?: string;
1051+
type: 'auth' | 'completion';
1052+
}> {
1053+
try {
1054+
const decoded = this.jwt_service.verify(exchange_token, {
1055+
secret:
1056+
process.env.OAUTH_EXCHANGE_TOKEN_SECRET ?? 'fallback-exchange-secret-change-me',
1057+
});
1058+
1059+
const { token_id, type } = decoded;
1060+
const redis_key = OAUTH_EXCHANGE_TOKEN_KEY(token_id);
1061+
const stored_data = await this.redis_service.get(redis_key);
1062+
1063+
if (!stored_data) {
1064+
throw new UnauthorizedException(ERROR_MESSAGES.INVALID_OR_EXPIRED_TOKEN);
1065+
}
1066+
1067+
await this.redis_service.del(redis_key);
1068+
const payload = JSON.parse(stored_data);
1069+
return { ...payload, type };
1070+
} catch (error) {
1071+
throw new UnauthorizedException(ERROR_MESSAGES.INVALID_OR_EXPIRED_TOKEN);
1072+
}
1073+
}
10251074
async confirmPassword(confirm_password_dto: ConfirmPasswordDto, user_id: string) {
10261075
const user = await this.user_repository.findById(user_id);
10271076

src/auth/auth.swagger.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,79 @@ export const verify_update_email_swagger = {
11411141
},
11421142
};
11431143

1144+
export const exchange_token_swagger = {
1145+
operation: {
1146+
summary: 'Exchange one-time token for credentials',
1147+
description: `
1148+
**Secure OAuth Token Exchange**
1149+
1150+
This endpoint exchanges a one-time exchange token (received from OAuth callback redirect) for the actual credentials.
1151+
1152+
**Security features:**
1153+
- One-time use only - token is deleted after exchange
1154+
- Short-lived (5 minutes)
1155+
- Encrypted and stored in Redis
1156+
- Prevents token exposure in URLs from being exploited
1157+
1158+
**Use cases:**
1159+
1. **After successful OAuth login**: Exchange token for access_token and refresh_token
1160+
2. **After OAuth requiring completion**: Exchange token for session_token to complete registration
1161+
1162+
**Flow:**
1163+
1. User authenticates via OAuth (Google/Facebook/GitHub)
1164+
2. Backend creates exchange token and stores only user_id or session_token in Redis (NO actual tokens stored)
1165+
3. User is redirected to frontend with exchange token in URL
1166+
4. Frontend immediately calls this endpoint to exchange token
1167+
5. Backend generates fresh access_token and refresh_token on-the-fly (for auth type)
1168+
6. Exchange token is deleted from Redis (single-use)
1169+
1170+
**Important:**
1171+
- The exchange token can only be used once and expires after 5 minutes
1172+
`,
1173+
},
1174+
1175+
responses: {
1176+
auth_success: {
1177+
description: 'Exchange successful - credentials returned (type: auth)',
1178+
schema: {
1179+
example: {
1180+
data: {
1181+
type: 'auth',
1182+
access_token:
1183+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImQxMDJkYWRjLTBiMTctNGU4My04MTJiLTAwMTAzYjYwNmExZiIsImlhdCI6MTc1ODE0Nzg2OSwiZXhwIjoxNzU4MTUxNDY5fQ.DV3oA5Fn-cj-KHrGcafGaoWGyvYFx4N50L9Ke4_n6OU',
1184+
},
1185+
count: 1,
1186+
message: SUCCESS_MESSAGES.TOKEN_EXCHANGE_SUCCESS,
1187+
},
1188+
},
1189+
headers: {
1190+
'Set-Cookie': {
1191+
description: 'HttpOnly cookie containing refresh token (only for auth type)',
1192+
schema: {
1193+
type: 'string',
1194+
example:
1195+
'refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; HttpOnly; Secure; SameSite=Strict',
1196+
},
1197+
},
1198+
},
1199+
},
1200+
completion_success: {
1201+
description: 'Exchange successful - session token returned (type: completion)',
1202+
schema: {
1203+
example: {
1204+
data: {
1205+
type: 'completion',
1206+
session_token: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
1207+
provider: 'google',
1208+
},
1209+
count: 1,
1210+
message: SUCCESS_MESSAGES.TOKEN_EXCHANGE_SUCCESS,
1211+
},
1212+
},
1213+
},
1214+
},
1215+
};
1216+
11441217
export const confirm_password_swagger = {
11451218
operation: {
11461219
summary: 'Confirm password',

src/auth/dto/exchange-token.dto.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
3+
import { LARGE_MAX_LENGTH } from 'src/constants/variables';
4+
5+
export class ExchangeTokenDto {
6+
@ApiProperty({
7+
description: 'One-time exchange token received from OAuth callback redirect',
8+
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
9+
})
10+
@IsString()
11+
@IsNotEmpty()
12+
@MaxLength(LARGE_MAX_LENGTH)
13+
exchange_token: string;
14+
}
Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
1-
import { Controller } from '@nestjs/common';
1+
import { Controller, Get } from '@nestjs/common';
2+
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
23
import { CategoryService } from './category.service';
4+
import { Category } from './entities';
5+
import { get_categories_swagger } from './category.swagger';
6+
import { ResponseMessage } from '../decorators/response-message.decorator';
7+
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants/swagger-messages';
8+
import { ApiInternalServerError } from '../decorators/swagger-error-responses.decorator';
39

10+
@ApiTags('Category')
411
@Controller('category')
512
export class CategoryController {
613
constructor(private readonly categoryService: CategoryService) {}
14+
15+
@ApiOperation(get_categories_swagger.operation)
16+
@ApiOkResponse(get_categories_swagger.responses.success)
17+
@ApiInternalServerError(ERROR_MESSAGES.FAILED_TO_FETCH_FROM_DB)
18+
@ResponseMessage(SUCCESS_MESSAGES.CATEGORIES_RETRIEVED)
19+
@Get()
20+
async getCategories(): Promise<string[]> {
21+
return await this.categoryService.getCategories();
22+
}
723
}

src/category/category.service.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,22 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { Injectable, InternalServerErrorException } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { Category } from './entities';
5+
import { ERROR_MESSAGES } from '../constants/swagger-messages';
26

37
@Injectable()
4-
export class CategoryService {}
8+
export class CategoryService {
9+
constructor(
10+
@InjectRepository(Category)
11+
private readonly categoryRepository: Repository<Category>
12+
) {}
13+
14+
async getCategories(): Promise<string[]> {
15+
try {
16+
const categories = await this.categoryRepository.find();
17+
return categories.map((category) => category.name);
18+
} catch (error) {
19+
throw new InternalServerErrorException(ERROR_MESSAGES.FAILED_TO_FETCH_FROM_DB);
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)