Skip to content

Commit 1d378a0

Browse files
committed
refactor(oauth2): make token endpoint private
1 parent bcf6d97 commit 1d378a0

File tree

8 files changed

+228
-32
lines changed

8 files changed

+228
-32
lines changed

src/auth/auth.controller.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,9 @@ export class AuthController {
163163
public async logout(
164164
@Req() req: FastifyRequest,
165165
@Res() res: FastifyReply,
166+
@Body() refreshAccessDto?: RefreshAccessDto,
166167
): Promise<void> {
167-
const token = this.refreshTokenFromReq(req);
168+
const token = this.refreshTokenFromReq(req, refreshAccessDto);
168169
const message = await this.authService.logout(token);
169170
res
170171
.clearCookie(this.cookieName, { path: this.cookiePath })

src/oauth2/dtos/token.dto.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { ApiProperty } from '@nestjs/swagger';
19-
import { IsString, Length } from 'class-validator';
19+
import { IsString, IsUrl, Length } from 'class-validator';
2020

2121
export abstract class TokenDto {
2222
@ApiProperty({
@@ -29,4 +29,13 @@ export abstract class TokenDto {
2929
@IsString()
3030
@Length(1, 22)
3131
public code: string;
32+
33+
@ApiProperty({
34+
description: 'Redirect URI that was used to get the token',
35+
example: 'https://example.com/auth/callback',
36+
type: String,
37+
})
38+
@IsString()
39+
@IsUrl()
40+
public redirectUri: string;
3241
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
Copyright (C) 2024 Afonso Barracha
3+
4+
Nest OAuth is free software: you can redistribute it and/or modify
5+
it under the terms of the GNU Lesser General Public License as published by
6+
the Free Software Foundation, either version 3 of the License, or
7+
(at your option) any later version.
8+
9+
Nest OAuth is distributed in the hope that it will be useful,
10+
but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
GNU Lesser General Public License for more details.
13+
14+
You should have received a copy of the GNU Lesser General Public License
15+
along with Nest OAuth. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
export interface ICallbackResult {
19+
readonly code: string;
20+
readonly accessToken: string;
21+
readonly expiresIn: number;
22+
}

src/oauth2/oauth2.controller.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
Post,
2424
Query,
2525
Res,
26+
UnauthorizedException,
2627
UseGuards,
2728
} from '@nestjs/common';
2829
import { ConfigService } from '@nestjs/config';
@@ -47,6 +48,7 @@ import {
4748
IMicrosoftUser,
4849
} from './interfaces/user-response.interface';
4950
import { Oauth2Service } from './oauth2.service';
51+
import { CurrentUser } from '../auth/decorators/current-user.decorator';
5052

5153
@ApiTags('Oauth2')
5254
@Controller('api/auth/ext')
@@ -210,7 +212,6 @@ export class Oauth2Controller {
210212
return this.callbackAndRedirect(res, provider, email, name);
211213
}
212214

213-
@Public()
214215
@Post('token')
215216
@ApiResponse({
216217
description: "Returns the user's OAuth 2 response",
@@ -220,10 +221,15 @@ export class Oauth2Controller {
220221
description: 'Code or state is invalid',
221222
})
222223
public async token(
224+
@CurrentUser() userId: number,
223225
@Body() tokenDto: TokenDto,
224226
@Res() res: FastifyReply,
225227
): Promise<void> {
226-
const result = await this.oauth2Service.token(tokenDto.code);
228+
if (tokenDto.redirectUri !== this.url + '/auth/callback') {
229+
throw new UnauthorizedException();
230+
}
231+
232+
const result = await this.oauth2Service.token(tokenDto.code, userId);
227233
return res
228234
.cookie(this.cookieName, result.refreshToken, {
229235
secure: !this.testing,
@@ -252,9 +258,20 @@ export class Oauth2Controller {
252258
email: string,
253259
name: string,
254260
): Promise<FastifyReply> {
255-
const code = await this.oauth2Service.callback(provider, email, name);
261+
const { code, accessToken, expiresIn } = await this.oauth2Service.callback(
262+
provider,
263+
email,
264+
name,
265+
);
266+
const urlSearchParams = new URLSearchParams({
267+
code,
268+
accessToken,
269+
tokenType: 'Bearer',
270+
expiresIn: expiresIn.toString(),
271+
});
272+
256273
return res
257274
.status(HttpStatus.ACCEPTED)
258-
.redirect(`${this.url}/callback?code=${code}`);
275+
.redirect(`${this.url}/auth/callback?${urlSearchParams.toString()}`);
259276
}
260277
}

src/oauth2/oauth2.service.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import { UsersService } from '../users/users.service';
3838
import { OAuthClass } from './classes/oauth.class';
3939
import { ICallbackQuery } from './interfaces/callback-query.interface';
4040
import { IClient } from './interfaces/client.interface';
41+
import { TokenTypeEnum } from '../jwt/enums/token-type.enum';
42+
import { ICallbackResult } from './interfaces/callback-result.interface';
4143

4244
@Injectable()
4345
export class Oauth2Service {
@@ -165,7 +167,7 @@ export class Oauth2Service {
165167
provider: OAuthProvidersEnum,
166168
email: string,
167169
name: string,
168-
): Promise<string> {
170+
): Promise<ICallbackResult> {
169171
const user = await this.usersService.findOrCreate(provider, email, name);
170172

171173
const code = Oauth2Service.generateCode();
@@ -177,22 +179,34 @@ export class Oauth2Service {
177179
),
178180
);
179181

180-
return code;
182+
const accessToken = await this.jwtService.generateToken(
183+
user,
184+
TokenTypeEnum.ACCESS,
185+
);
186+
return {
187+
code,
188+
accessToken,
189+
expiresIn: this.jwtService.accessTime,
190+
};
181191
}
182192

183-
public async token(code: string): Promise<IAuthResult> {
193+
public async token(code: string, userId: number): Promise<IAuthResult> {
184194
const codeKey = Oauth2Service.getOAuthCodeKey(code);
185195
const email = await this.commonService.throwInternalError(
186196
this.cacheManager.get<string>(codeKey),
187197
);
188198

189199
if (!email) {
190-
throw new UnauthorizedException('Code is invalid or expired');
200+
throw new UnauthorizedException();
191201
}
192202

193203
await this.commonService.throwInternalError(this.cacheManager.del(codeKey));
194-
195204
const user = await this.usersService.findOneByEmail(email);
205+
206+
if (user.id !== userId) {
207+
throw new UnauthorizedException();
208+
}
209+
196210
const [accessToken, refreshToken] =
197211
await this.jwtService.generateAuthTokens(user);
198212
return {

src/oauth2/tests/oauth2.service.spec.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { OAuthProvidersEnum } from '../../users/enums/oauth-providers.enum';
3434
import { UsersModule } from '../../users/users.module';
3535
import { UsersService } from '../../users/users.service';
3636
import { Oauth2Service } from '../oauth2.service';
37+
import { isJWT } from 'class-validator';
3738

3839
describe('Oauth2Service', () => {
3940
let module: TestingModule,
@@ -121,14 +122,19 @@ describe('Oauth2Service', () => {
121122
it('should create a new user', async () => {
122123
const email = faker.internet.email();
123124
const name = faker.person.fullName();
124-
const code = await oauth2Service.callback(
125+
const result = await oauth2Service.callback(
125126
OAuthProvidersEnum.GOOGLE,
126127
email,
127128
name,
128129
);
129130

130-
expect(code).toBeDefined();
131-
expect(code.length).toBe(22);
131+
expect(result).toMatchObject({
132+
accessToken: expect.any(String),
133+
code: expect.any(String),
134+
expiresIn: expect.any(Number),
135+
});
136+
expect(isJWT(result.accessToken)).toBe(true);
137+
expect(result.code).toHaveLength(22);
132138

133139
const user = await usersService.findOneByEmail(email);
134140
expect(user).toBeDefined();
@@ -144,14 +150,19 @@ describe('Oauth2Service', () => {
144150
const email = faker.internet.email();
145151
const name = faker.person.fullName();
146152
await usersService.create(OAuthProvidersEnum.GOOGLE, email, name);
147-
const code = await oauth2Service.callback(
153+
const result = await oauth2Service.callback(
148154
OAuthProvidersEnum.MICROSOFT,
149155
email,
150156
name,
151157
);
152158

153-
expect(code).toBeDefined();
154-
expect(code.length).toBe(22);
159+
expect(result).toMatchObject({
160+
accessToken: expect.any(String),
161+
code: expect.any(String),
162+
expiresIn: expect.any(Number),
163+
});
164+
expect(isJWT(result.accessToken)).toBe(true);
165+
expect(result.code).toHaveLength(22);
155166

156167
const user = await usersService.findOneByEmail(email);
157168
expect(user).toBeDefined();
@@ -166,15 +177,16 @@ describe('Oauth2Service', () => {
166177

167178
describe('token', () => {
168179
it('should return access and refresh tokens from callback code', async () => {
169-
const email = faker.internet.email();
180+
const email = faker.internet.email().toLowerCase();
170181
const name = faker.person.fullName();
171-
const code = await oauth2Service.callback(
172-
OAuthProvidersEnum.MICROSOFT,
182+
const { code } = await oauth2Service.callback(
183+
OAuthProvidersEnum.GOOGLE,
173184
email,
174185
name,
175186
);
187+
const user = await usersService.findOneByEmail(email);
176188

177-
const result = await oauth2Service.token(code);
189+
const result = await oauth2Service.token(code, user.id);
178190

179191
expect(result).toMatchObject({
180192
user: expect.any(UserEntity),
@@ -184,11 +196,14 @@ describe('Oauth2Service', () => {
184196
});
185197

186198
it('should throw an unauthorized exception for invalid callback code', async () => {
187-
const code = '7IHq0AGB7FOL25kt8WejRz';
199+
const email = faker.internet.email().toLowerCase();
200+
const name = faker.person.fullName();
201+
await oauth2Service.callback(OAuthProvidersEnum.MICROSOFT, email, name);
202+
const user = await usersService.findOneByEmail(email);
188203

189-
await expect(oauth2Service.token(code)).rejects.toThrow(
190-
new UnauthorizedException('Code is invalid or expired'),
191-
);
204+
await expect(
205+
oauth2Service.token('7IHq0AGB7FOL25kt8WejRz', user.id),
206+
).rejects.toThrow(new UnauthorizedException());
192207
});
193208
});
194209

test/app.e2e-spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ describe('AppController (e2e)', () => {
366366
.expect(HttpStatus.OK);
367367
});
368368

369-
it('should logout the user with refresh bodie', async () => {
369+
it('should logout the user with refresh body', async () => {
370370
const user = await usersService.findOneByEmail(email);
371371
const [accessToken, refreshToken] =
372372
await jwtService.generateAuthTokens(user);
@@ -554,6 +554,75 @@ describe('AppController (e2e)', () => {
554554
});
555555
});
556556
});
557+
558+
describe('refresh-access', () => {
559+
const refreshPath = `${baseUrl}/refresh-access`;
560+
561+
it('should return 200 OK with auth response with the refresh token in the cookies', async () => {
562+
const signInRes = await request(app.getHttpServer())
563+
.post(`${baseUrl}/sign-in`)
564+
.send({
565+
emailOrUsername: email,
566+
password,
567+
})
568+
.expect(HttpStatus.OK);
569+
570+
return request(app.getHttpServer())
571+
.post(refreshPath)
572+
.set('Authorization', `Bearer ${signInRes.body.accessToken}`)
573+
.set('Cookie', signInRes.header['set-cookie'])
574+
.expect(HttpStatus.OK)
575+
.expect((res) => {
576+
expect(res.body).toMatchObject({
577+
accessToken: expect.any(String),
578+
refreshToken: expect.any(String),
579+
expiresIn: expect.any(Number),
580+
tokenType: 'Bearer',
581+
user: {
582+
id: expect.any(Number),
583+
name: commonService.formatName(name),
584+
username: commonService.generatePointSlug(name),
585+
email,
586+
},
587+
});
588+
});
589+
});
590+
591+
it('should return 200 OK with auth response with the refresh token in the body', async () => {
592+
const user = await usersService.findOneByEmail(email);
593+
const [accessToken, refreshToken] =
594+
await jwtService.generateAuthTokens(user);
595+
return request(app.getHttpServer())
596+
.post(refreshPath)
597+
.set('Authorization', `Bearer ${accessToken}`)
598+
.send({ refreshToken })
599+
.expect(HttpStatus.OK)
600+
.expect((res) => {
601+
expect(res.body).toMatchObject({
602+
accessToken: expect.any(String),
603+
refreshToken: expect.any(String),
604+
expiresIn: expect.any(Number),
605+
tokenType: 'Bearer',
606+
user: {
607+
id: expect.any(Number),
608+
name: commonService.formatName(name),
609+
username: commonService.generatePointSlug(name),
610+
email,
611+
},
612+
});
613+
});
614+
});
615+
616+
it('should return 401 UNAUTHORIZED when refresh token is not passed', async () => {
617+
const user = await usersService.findOneByEmail(email);
618+
const [accessToken] = await jwtService.generateAuthTokens(user);
619+
620+
return request(app.getHttpServer())
621+
.post(refreshPath)
622+
.set('Authorization', `Bearer ${accessToken}`)
623+
.expect(HttpStatus.UNAUTHORIZED);
624+
});
625+
});
557626
});
558627

559628
describe('api/users', () => {

0 commit comments

Comments
 (0)