From 4cc85b6687b7d81a5ac53824bd29f085a1cf8b1e Mon Sep 17 00:00:00 2001 From: Afonso Barracha Date: Fri, 23 Aug 2024 00:01:19 +1200 Subject: [PATCH 1/5] feat(oauth2): add token endpoint --- src/auth/auth.controller.ts | 15 +++- src/auth/dtos/refresh-access.dto.ts | 32 +++++++ .../interfaces/auth-response.interface.ts | 2 + src/auth/mappers/auth-response.mapper.ts | 17 ++++ src/oauth2/dtos/token.dto.ts | 43 +++++++++ src/oauth2/oauth2.controller.ts | 64 +++++++++---- src/oauth2/oauth2.service.ts | 89 +++++++++++++++++-- src/oauth2/tests/oauth2.service.spec.ts | 4 +- 8 files changed, 236 insertions(+), 30 deletions(-) create mode 100644 src/auth/dtos/refresh-access.dto.ts create mode 100644 src/oauth2/dtos/token.dto.ts diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 008b8ba..cf02e64 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -49,6 +49,7 @@ import { Public } from './decorators/public.decorator'; import { ChangePasswordDto } from './dtos/change-password.dto'; import { ConfirmEmailDto } from './dtos/confirm-email.dto'; import { EmailDto } from './dtos/email.dto'; +import { RefreshAccessDto } from './dtos/refresh-access.dto'; import { ResetPasswordDto } from './dtos/reset-password.dto'; import { SignInDto } from './dtos/sign-in.dto'; import { SignUpDto } from './dtos/sign-up.dto'; @@ -136,8 +137,9 @@ export class AuthController { public async refreshAccess( @Req() req: FastifyRequest, @Res() res: FastifyReply, + @Body() refreshAccessDto?: RefreshAccessDto, ): Promise { - const token = this.refreshTokenFromReq(req); + const token = this.refreshTokenFromReq(req, refreshAccessDto); const result = await this.authService.refreshTokenAccess( token, req.headers.origin, @@ -189,7 +191,7 @@ export class AuthController { @Body() confirmEmailDto: ConfirmEmailDto, @Res() res: FastifyReply, ): Promise { - const result = await this.authService.confirmEmail(confirmEmailDto); + const result = await this.authService.confirmEmail(confirmEmailDto, origin); this.saveRefreshCookie(res, result.refreshToken) .status(HttpStatus.OK) .send(AuthResponseMapper.map(result)); @@ -279,10 +281,17 @@ export class AuthController { return OAuthProvidersResponseMapper.map(providers); } - private refreshTokenFromReq(req: FastifyRequest): string { + private refreshTokenFromReq( + req: FastifyRequest, + dto?: RefreshAccessDto, + ): string { const token: string | undefined = req.cookies[this.cookieName]; if (isUndefined(token) || isNull(token)) { + if (!isUndefined(dto?.refreshToken)) { + return dto.refreshToken; + } + throw new UnauthorizedException(); } diff --git a/src/auth/dtos/refresh-access.dto.ts b/src/auth/dtos/refresh-access.dto.ts new file mode 100644 index 0000000..99f8868 --- /dev/null +++ b/src/auth/dtos/refresh-access.dto.ts @@ -0,0 +1,32 @@ +/* + Copyright (C) 2024 Afonso Barracha + + Nest OAuth is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Nest OAuth is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with Nest OAuth. If not, see . +*/ + +import { ApiProperty } from '@nestjs/swagger'; +import { IsJWT, IsOptional, IsString } from 'class-validator'; + +export abstract class RefreshAccessDto { + @ApiProperty({ + description: 'The JWT token sent to the user email', + example: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + type: String, + }) + @IsOptional() + @IsString() + @IsJWT() + public refreshToken?: string; +} diff --git a/src/auth/interfaces/auth-response.interface.ts b/src/auth/interfaces/auth-response.interface.ts index c8677c3..70d06e2 100644 --- a/src/auth/interfaces/auth-response.interface.ts +++ b/src/auth/interfaces/auth-response.interface.ts @@ -20,4 +20,6 @@ import { IAuthResponseUser } from './auth-response-user.interface'; export interface IAuthResponse { user: IAuthResponseUser; accessToken: string; + refreshToken: string; + tokenType: string; } diff --git a/src/auth/mappers/auth-response.mapper.ts b/src/auth/mappers/auth-response.mapper.ts index 8e99df6..70d0dd1 100644 --- a/src/auth/mappers/auth-response.mapper.ts +++ b/src/auth/mappers/auth-response.mapper.ts @@ -35,6 +35,21 @@ export class AuthResponseMapper implements IAuthResponse { }) public readonly accessToken: string; + @ApiProperty({ + description: 'Refresh token', + example: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + type: String, + }) + public readonly refreshToken: string; + + @ApiProperty({ + description: 'Token type', + example: 'Bearer', + type: String, + }) + public readonly tokenType: string; + constructor(values: IAuthResponse) { Object.assign(this, values); } @@ -43,6 +58,8 @@ export class AuthResponseMapper implements IAuthResponse { return new AuthResponseMapper({ user: AuthResponseUserMapper.map(result.user), accessToken: result.accessToken, + refreshToken: result.refreshToken, + tokenType: 'Bearer', }); } } diff --git a/src/oauth2/dtos/token.dto.ts b/src/oauth2/dtos/token.dto.ts new file mode 100644 index 0000000..8480dc7 --- /dev/null +++ b/src/oauth2/dtos/token.dto.ts @@ -0,0 +1,43 @@ +/* + Copyright (C) 2024 Afonso Barracha + + Nest OAuth is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Nest OAuth is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with Nest OAuth. If not, see . +*/ + +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Length } from 'class-validator'; + +export abstract class TokenDto { + @ApiProperty({ + description: 'The Code to exchange for a token', + example: '5WA0R4DVyWThKFnc73z7nT', + minLength: 1, + maxLength: 22, + type: String, + }) + @IsString() + @Length(1, 22) + public code: string; + + @ApiProperty({ + description: 'The hex state for exchanging the token', + example: 'cb85f0214feefbff8c7923cb9790a3f2', + minLength: 1, + maxLength: 35, + type: String, + }) + @IsString() + @Length(1, 32) + public state: string; +} diff --git a/src/oauth2/oauth2.controller.ts b/src/oauth2/oauth2.controller.ts index 4b95e36..13a4f4e 100644 --- a/src/oauth2/oauth2.controller.ts +++ b/src/oauth2/oauth2.controller.ts @@ -16,20 +16,30 @@ */ import { + Body, Controller, Get, HttpStatus, + Post, Query, Res, UseGuards, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ApiNotFoundResponse, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiNotFoundResponse, + ApiResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; import { FastifyReply } from 'fastify'; +import { URLSearchParams } from 'url'; import { Public } from '../auth/decorators/public.decorator'; import { FastifyThrottlerGuard } from '../auth/guards/fastify-throttler.guard'; +import { AuthResponseMapper } from '../auth/mappers/auth-response.mapper'; import { OAuthProvidersEnum } from '../users/enums/oauth-providers.enum'; import { CallbackQueryDto } from './dtos/callback-query.dto'; +import { TokenDto } from './dtos/token.dto'; import { OAuthFlagGuard } from './guards/oauth-flag.guard'; import { IFacebookUser, @@ -90,7 +100,7 @@ export class Oauth2Controller { const provider = OAuthProvidersEnum.MICROSOFT; const { displayName, mail } = await this.oauth2Service.getUserData(provider, cbQuery); - return this.loginAndRedirect(res, provider, mail, displayName); + return this.callbackAndRedirect(res, provider, mail, displayName); } @Public() @@ -126,7 +136,7 @@ export class Oauth2Controller { provider, cbQuery, ); - return this.loginAndRedirect(res, provider, email, name); + return this.callbackAndRedirect(res, provider, email, name); } @Public() @@ -162,7 +172,7 @@ export class Oauth2Controller { provider, cbQuery, ); - return this.loginAndRedirect(res, provider, email, name); + return this.callbackAndRedirect(res, provider, email, name); } @Public() @@ -184,7 +194,7 @@ export class Oauth2Controller { @Get('github/callback') @ApiResponse({ description: 'Redirects to the frontend with the JWT token', - status: HttpStatus.PERMANENT_REDIRECT, + status: HttpStatus.ACCEPTED, }) @ApiNotFoundResponse({ description: 'OAuth2 is not enabled for GitHub', @@ -198,7 +208,33 @@ export class Oauth2Controller { provider, cbQuery, ); - return this.loginAndRedirect(res, provider, email, name); + return this.callbackAndRedirect(res, provider, email, name); + } + + @Public() + @Post('token') + @ApiResponse({ + description: "Returns the user's OAuth 2 response", + status: HttpStatus.OK, + }) + @ApiUnauthorizedResponse({ + description: 'Code or state is invalid', + }) + public async token( + @Body() tokenDto: TokenDto, + @Res() res: FastifyReply, + ): Promise { + const result = await this.oauth2Service.token(tokenDto); + return res + .cookie(this.cookieName, result.refreshToken, { + secure: !this.testing, + httpOnly: true, + signed: true, + path: this.cookiePath, + expires: new Date(Date.now() + this.refreshTime * 1000), + }) + .header('Content-Type', 'application/json') + .send(AuthResponseMapper.map(result)); } private async startRedirect( @@ -210,26 +246,20 @@ export class Oauth2Controller { .redirect(await this.oauth2Service.getAuthorizationUrl(provider)); } - private async loginAndRedirect( + private async callbackAndRedirect( res: FastifyReply, provider: OAuthProvidersEnum, email: string, name: string, ): Promise { - const [accessToken, refreshToken] = await this.oauth2Service.login( + const [code, state] = await this.oauth2Service.callback( provider, email, name, ); + const urlParams = new URLSearchParams({ code, state }); return res - .cookie(this.cookieName, refreshToken, { - secure: !this.testing, - httpOnly: true, - signed: true, - path: this.cookiePath, - expires: new Date(Date.now() + this.refreshTime * 1000), - }) - .status(HttpStatus.PERMANENT_REDIRECT) - .redirect(`${this.url}/?access_token=${accessToken}`); + .status(HttpStatus.ACCEPTED) + .redirect(`${this.url}/?${urlParams.toString()}`); } } diff --git a/src/oauth2/oauth2.service.ts b/src/oauth2/oauth2.service.ts index 0c5fd9a..d81c0a6 100644 --- a/src/oauth2/oauth2.service.ts +++ b/src/oauth2/oauth2.service.ts @@ -16,6 +16,7 @@ */ import { HttpService } from '@nestjs/axios'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, @@ -24,20 +25,27 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AxiosError } from 'axios'; +import { Cache } from 'cache-manager'; +import { randomBytes } from 'crypto'; import { catchError, firstValueFrom } from 'rxjs'; +import { v4 } from 'uuid'; +import { IAuthResult } from '../auth/interfaces/auth-result.interface'; import { CommonService } from '../common/common.service'; import { isNull } from '../common/utils/validation.util'; import { JwtService } from '../jwt/jwt.service'; import { OAuthProvidersEnum } from '../users/enums/oauth-providers.enum'; import { UsersService } from '../users/users.service'; import { OAuthClass } from './classes/oauth.class'; +import { TokenDto } from './dtos/token.dto'; import { ICallbackQuery } from './interfaces/callback-query.interface'; import { IClient } from './interfaces/client.interface'; -import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { Cache } from 'cache-manager'; @Injectable() export class Oauth2Service { + private static readonly BASE62 = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + private static readonly BIG62 = BigInt(Oauth2Service.BASE62.length); + private readonly [OAuthProvidersEnum.MICROSOFT]: OAuthClass | null; private readonly [OAuthProvidersEnum.GOOGLE]: OAuthClass | null; private readonly [OAuthProvidersEnum.FACEBOOK]: OAuthClass | null; @@ -91,12 +99,37 @@ export class Oauth2Service { return new OAuthClass(provider, client, url); } + private static getOAuthStateKey(state: string): string { + return `oauth_state:${state}`; + } + + private static getOAuthCodeKey(code: string): string { + return `oauth_code:${code}`; + } + + private static generateCode(): string { + let num = BigInt('0x' + v4().replace(/-/g, '')); + let code = ''; + + while (num > 0) { + const remainder = Number(num % Oauth2Service.BIG62); + code = Oauth2Service.BASE62[remainder] + code; + num = num / Oauth2Service.BIG62; + } + + return code.padStart(22, '0'); + } + public async getAuthorizationUrl( provider: OAuthProvidersEnum, ): Promise { const [url, state] = this.getOAuth(provider).authorizationUrl; await this.commonService.throwInternalError( - this.cacheManager.set(this.getOAuthStateKey(state), provider, 120 * 1000), + this.cacheManager.set( + Oauth2Service.getOAuthStateKey(state), + provider, + 120 * 1000, + ), ); return url; } @@ -124,17 +157,57 @@ export class Oauth2Service { return userData.data; } - public async login( + public async callback( provider: OAuthProvidersEnum, email: string, name: string, ): Promise<[string, string]> { const user = await this.usersService.findOrCreate(provider, email, name); - return this.jwtService.generateAuthTokens(user); + + const code = Oauth2Service.generateCode(); + await this.commonService.throwInternalError( + this.cacheManager.set( + Oauth2Service.getOAuthCodeKey(code), + user.email, + 120 * 1000, + ), + ); + + const state = randomBytes(16).toString('hex'); + await this.commonService.throwInternalError( + this.cacheManager.set( + Oauth2Service.getOAuthStateKey(state), + OAuthProvidersEnum.LOCAL, + 120 * 1000, + ), + ); + + return [code, state]; } - private getOAuthStateKey(state: string): string { - return `oauth_state:${state}`; + public async token(dto: TokenDto): Promise { + const email = await this.commonService.throwInternalError( + this.cacheManager.get(Oauth2Service.getOAuthCodeKey(dto.code)), + ); + + if (!email) { + throw new UnauthorizedException('Code is invalid or expired'); + } + + const provider = await this.commonService.throwInternalError( + this.cacheManager.get( + Oauth2Service.getOAuthStateKey(dto.state), + ), + ); + + if (!provider || provider !== OAuthProvidersEnum.LOCAL) { + throw new UnauthorizedException('Corrupted state'); + } + + const user = await this.usersService.findOneByEmail(email); + const [accessToken, refreshToken] = + await this.jwtService.generateAuthTokens(user); + return { user, accessToken, refreshToken }; } private getOAuth(provider: OAuthProvidersEnum): OAuthClass { @@ -154,7 +227,7 @@ export class Oauth2Service { ): Promise { const oauth = this.getOAuth(provider); const stateProvider = await this.cacheManager.get( - this.getOAuthStateKey(state), + Oauth2Service.getOAuthStateKey(state), ); if (!stateProvider || provider !== stateProvider) { diff --git a/src/oauth2/tests/oauth2.service.spec.ts b/src/oauth2/tests/oauth2.service.spec.ts index c77a9a5..a022180 100644 --- a/src/oauth2/tests/oauth2.service.spec.ts +++ b/src/oauth2/tests/oauth2.service.spec.ts @@ -121,7 +121,7 @@ describe('Oauth2Service', () => { const name = faker.name.fullName(); it('should create a new user', async () => { - const [accessToken, refreshToken] = await oauth2Service.login( + const [accessToken, refreshToken] = await oauth2Service.callback( OAuthProvidersEnum.GOOGLE, email, name, @@ -144,7 +144,7 @@ describe('Oauth2Service', () => { }); it('should login an existing user', async () => { - const [accessToken, refreshToken] = await oauth2Service.login( + const [accessToken, refreshToken] = await oauth2Service.callback( OAuthProvidersEnum.MICROSOFT, email, name, From bcf6d97f6d301d30af968363a8eb80d62987db95 Mon Sep 17 00:00:00 2001 From: Afonso Barracha Date: Sat, 24 Aug 2024 01:28:29 +1200 Subject: [PATCH 2/5] feat: add token endpoint and generalize api for mobile --- .env.example | 2 +- README.md | 2 +- package.json | 3 +- src/auth/auth.service.ts | 28 +- .../interfaces/auth-response.interface.ts | 1 + src/auth/interfaces/auth-result.interface.ts | 1 + src/auth/mappers/auth-response.mapper.ts | 8 + src/common/common.service.ts | 15 + src/jwt/jwt.service.ts | 4 + src/oauth2/dtos/token.dto.ts | 11 - src/oauth2/guards/oauth-flag.guard.ts | 2 + src/oauth2/oauth2.controller.ts | 19 +- src/oauth2/oauth2.service.ts | 57 ++-- src/oauth2/tests/oauth2.service.spec.ts | 59 ++-- test/app.e2e-spec.ts | 21 +- test/oauth2.e2e-spec.ts | 279 ++++++++++++++++++ yarn.lock | 52 ++-- 17 files changed, 456 insertions(+), 108 deletions(-) create mode 100644 test/oauth2.e2e-spec.ts diff --git a/.env.example b/.env.example index adf1b79..c40a144 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -c# change to production before deploying +# change to production before deploying NODE_ENV='development' PORT=5000 APP_ID='00000000-0000-0000-0000-000000000000' diff --git a/README.md b/README.md index 88fba28..1be2694 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is the source code for the tutorial [Nest Authentication with OAuth2.0](https://dev.to/tugascript/nestjs-authentication-with-oauth20-adding-external-providers-2kj). -This is the 5th and last part on a 5 part series, where we will build a production level NestJS OAuth2 +This is the 6th and extra part on a 5 part series, where we will build a production level NestJS OAuth2 service. ### Contents diff --git a/package.json b/package.json index 0e2eb9e..20d7de7 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "class-transformer": "0.5.1", "class-validator": "0.14.1", "dayjs": "1.11.12", - "fastify": "4.28.0", + "fastify": "4.28.1", "handlebars": "4.7.8", "ioredis": "5.4.1", "joi": "17.13.3", @@ -82,6 +82,7 @@ "eslint-plugin-header": "3.1.1", "eslint-plugin-prettier": "5.2.1", "jest": "29.7.0", + "nock": "13.5.5", "prettier": "3.3.3", "source-map-support": "0.5.21", "supertest": "7.0.0", diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index a6179b3..a4ff0dc 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -88,7 +88,12 @@ export class AuthService { const user = await this.usersService.confirmEmail(id, version); const [accessToken, refreshToken] = await this.jwtService.generateAuthTokens(user, domain); - return { user, accessToken, refreshToken }; + return { + user, + accessToken, + refreshToken, + expiresIn: this.jwtService.accessTime, + }; } public async signIn(dto: SignInDto, domain?: string): Promise { @@ -112,7 +117,12 @@ export class AuthService { const [accessToken, refreshToken] = await this.jwtService.generateAuthTokens(user, domain); - return { user, accessToken, refreshToken }; + return { + user, + accessToken, + refreshToken, + expiresIn: this.jwtService.accessTime, + }; } public async refreshTokenAccess( @@ -128,7 +138,12 @@ export class AuthService { const user = await this.usersService.findOneByCredentials(id, version); const [accessToken, newRefreshToken] = await this.jwtService.generateAuthTokens(user, domain, tokenId); - return { user, accessToken, refreshToken: newRefreshToken }; + return { + user, + accessToken, + refreshToken: newRefreshToken, + expiresIn: this.jwtService.accessTime, + }; } public async logout(refreshToken: string): Promise { @@ -184,7 +199,12 @@ export class AuthService { ); const [accessToken, refreshToken] = await this.jwtService.generateAuthTokens(user, domain); - return { user, accessToken, refreshToken }; + return { + user, + accessToken, + refreshToken, + expiresIn: this.jwtService.accessTime, + }; } private async checkLastPassword( diff --git a/src/auth/interfaces/auth-response.interface.ts b/src/auth/interfaces/auth-response.interface.ts index 70d06e2..834ba1b 100644 --- a/src/auth/interfaces/auth-response.interface.ts +++ b/src/auth/interfaces/auth-response.interface.ts @@ -22,4 +22,5 @@ export interface IAuthResponse { accessToken: string; refreshToken: string; tokenType: string; + expiresIn: number; } diff --git a/src/auth/interfaces/auth-result.interface.ts b/src/auth/interfaces/auth-result.interface.ts index 7fec56d..e4d19fe 100644 --- a/src/auth/interfaces/auth-result.interface.ts +++ b/src/auth/interfaces/auth-result.interface.ts @@ -21,4 +21,5 @@ export interface IAuthResult { user: IUser; accessToken: string; refreshToken: string; + expiresIn: number; } diff --git a/src/auth/mappers/auth-response.mapper.ts b/src/auth/mappers/auth-response.mapper.ts index 70d0dd1..3398302 100644 --- a/src/auth/mappers/auth-response.mapper.ts +++ b/src/auth/mappers/auth-response.mapper.ts @@ -50,6 +50,13 @@ export class AuthResponseMapper implements IAuthResponse { }) public readonly tokenType: string; + @ApiProperty({ + description: 'Expiration period in seconds', + example: 3600, + type: Number, + }) + public readonly expiresIn: number; + constructor(values: IAuthResponse) { Object.assign(this, values); } @@ -60,6 +67,7 @@ export class AuthResponseMapper implements IAuthResponse { accessToken: result.accessToken, refreshToken: result.refreshToken, tokenType: 'Bearer', + expiresIn: result.expiresIn, }); } } diff --git a/src/common/common.service.ts b/src/common/common.service.ts index 125f279..535e4d3 100644 --- a/src/common/common.service.ts +++ b/src/common/common.service.ts @@ -24,6 +24,7 @@ import { Logger, LoggerService, NotFoundException, + UnauthorizedException, } from '@nestjs/common'; import { validate } from 'class-validator'; import slugify from 'slugify'; @@ -131,6 +132,20 @@ export class CommonService { } } + /** + * Throw Unauthorized + * + * Function to abstract throwing unauthorized exceptionm + */ + public async throwUnauthorizedError(promise: Promise): Promise { + try { + return await promise; + } catch (error) { + this.loggerService.error(error); + throw new UnauthorizedException(); + } + } + /** * Format Name * diff --git a/src/jwt/jwt.service.ts b/src/jwt/jwt.service.ts index a9e1dfd..4da1f65 100644 --- a/src/jwt/jwt.service.ts +++ b/src/jwt/jwt.service.ts @@ -52,6 +52,10 @@ export class JwtService { this.domain = this.configService.get('domain'); } + public get accessTime(): number { + return this.jwtConfig.access.time; + } + private static async generateTokenAsync( payload: IAccessPayload | IEmailPayload | IRefreshPayload, secret: string, diff --git a/src/oauth2/dtos/token.dto.ts b/src/oauth2/dtos/token.dto.ts index 8480dc7..3d5a1d0 100644 --- a/src/oauth2/dtos/token.dto.ts +++ b/src/oauth2/dtos/token.dto.ts @@ -29,15 +29,4 @@ export abstract class TokenDto { @IsString() @Length(1, 22) public code: string; - - @ApiProperty({ - description: 'The hex state for exchanging the token', - example: 'cb85f0214feefbff8c7923cb9790a3f2', - minLength: 1, - maxLength: 35, - type: String, - }) - @IsString() - @Length(1, 32) - public state: string; } diff --git a/src/oauth2/guards/oauth-flag.guard.ts b/src/oauth2/guards/oauth-flag.guard.ts index 73d91a5..1c46186 100644 --- a/src/oauth2/guards/oauth-flag.guard.ts +++ b/src/oauth2/guards/oauth-flag.guard.ts @@ -18,6 +18,7 @@ import { CanActivate, ExecutionContext, + Injectable, mixin, NotFoundException, Type, @@ -31,6 +32,7 @@ import { IClient } from '../interfaces/client.interface'; export const OAuthFlagGuard = ( provider: OAuthProvidersEnum, ): Type => { + @Injectable() class OAuthFlagGuardClass implements CanActivate { constructor(private readonly configService: ConfigService) {} diff --git a/src/oauth2/oauth2.controller.ts b/src/oauth2/oauth2.controller.ts index 13a4f4e..0915946 100644 --- a/src/oauth2/oauth2.controller.ts +++ b/src/oauth2/oauth2.controller.ts @@ -33,7 +33,6 @@ import { ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { FastifyReply } from 'fastify'; -import { URLSearchParams } from 'url'; import { Public } from '../auth/decorators/public.decorator'; import { FastifyThrottlerGuard } from '../auth/guards/fastify-throttler.guard'; import { AuthResponseMapper } from '../auth/mappers/auth-response.mapper'; @@ -88,7 +87,7 @@ export class Oauth2Controller { @Get('microsoft/callback') @ApiResponse({ description: 'Redirects to the frontend with the JWT token', - status: HttpStatus.PERMANENT_REDIRECT, + status: HttpStatus.ACCEPTED, }) @ApiNotFoundResponse({ description: 'OAuth2 is not enabled for Microsoft', @@ -122,7 +121,7 @@ export class Oauth2Controller { @Get('google/callback') @ApiResponse({ description: 'Redirects to the frontend with the JWT token', - status: HttpStatus.PERMANENT_REDIRECT, + status: HttpStatus.ACCEPTED, }) @ApiNotFoundResponse({ description: 'OAuth2 is not enabled for Google', @@ -158,7 +157,7 @@ export class Oauth2Controller { @Get('facebook/callback') @ApiResponse({ description: 'Redirects to the frontend with the JWT token', - status: HttpStatus.PERMANENT_REDIRECT, + status: HttpStatus.ACCEPTED, }) @ApiNotFoundResponse({ description: 'OAuth2 is not enabled for Facebook', @@ -224,7 +223,7 @@ export class Oauth2Controller { @Body() tokenDto: TokenDto, @Res() res: FastifyReply, ): Promise { - const result = await this.oauth2Service.token(tokenDto); + const result = await this.oauth2Service.token(tokenDto.code); return res .cookie(this.cookieName, result.refreshToken, { secure: !this.testing, @@ -234,6 +233,7 @@ export class Oauth2Controller { expires: new Date(Date.now() + this.refreshTime * 1000), }) .header('Content-Type', 'application/json') + .status(HttpStatus.OK) .send(AuthResponseMapper.map(result)); } @@ -252,14 +252,9 @@ export class Oauth2Controller { email: string, name: string, ): Promise { - const [code, state] = await this.oauth2Service.callback( - provider, - email, - name, - ); - const urlParams = new URLSearchParams({ code, state }); + const code = await this.oauth2Service.callback(provider, email, name); return res .status(HttpStatus.ACCEPTED) - .redirect(`${this.url}/?${urlParams.toString()}`); + .redirect(`${this.url}/callback?code=${code}`); } } diff --git a/src/oauth2/oauth2.service.ts b/src/oauth2/oauth2.service.ts index d81c0a6..e6043e9 100644 --- a/src/oauth2/oauth2.service.ts +++ b/src/oauth2/oauth2.service.ts @@ -18,6 +18,7 @@ import { HttpService } from '@nestjs/axios'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { + HttpStatus, Inject, Injectable, NotFoundException, @@ -26,7 +27,6 @@ import { import { ConfigService } from '@nestjs/config'; import { AxiosError } from 'axios'; import { Cache } from 'cache-manager'; -import { randomBytes } from 'crypto'; import { catchError, firstValueFrom } from 'rxjs'; import { v4 } from 'uuid'; import { IAuthResult } from '../auth/interfaces/auth-result.interface'; @@ -36,7 +36,6 @@ import { JwtService } from '../jwt/jwt.service'; import { OAuthProvidersEnum } from '../users/enums/oauth-providers.enum'; import { UsersService } from '../users/users.service'; import { OAuthClass } from './classes/oauth.class'; -import { TokenDto } from './dtos/token.dto'; import { ICallbackQuery } from './interfaces/callback-query.interface'; import { IClient } from './interfaces/client.interface'; @@ -128,7 +127,7 @@ export class Oauth2Service { this.cacheManager.set( Oauth2Service.getOAuthStateKey(state), provider, - 120 * 1000, + 120_000, ), ); return url; @@ -140,11 +139,11 @@ export class Oauth2Service { ): Promise { const { code, state } = cbQuery; const accessToken = await this.getAccessToken(provider, code, state); - const userData = await firstValueFrom( + const userReq = await firstValueFrom( this.httpService .get(this.getOAuth(provider).dataUrl, { headers: { - 'Content-Type': 'application/json', + Accept: 'application/json', Authorization: `Bearer ${accessToken}`, }, }) @@ -154,14 +153,19 @@ export class Oauth2Service { }), ), ); - return userData.data; + + if (userReq.status !== HttpStatus.OK) { + throw new UnauthorizedException(); + } + + return userReq.data; } public async callback( provider: OAuthProvidersEnum, email: string, name: string, - ): Promise<[string, string]> { + ): Promise { const user = await this.usersService.findOrCreate(provider, email, name); const code = Oauth2Service.generateCode(); @@ -169,45 +173,34 @@ export class Oauth2Service { this.cacheManager.set( Oauth2Service.getOAuthCodeKey(code), user.email, - 120 * 1000, - ), - ); - - const state = randomBytes(16).toString('hex'); - await this.commonService.throwInternalError( - this.cacheManager.set( - Oauth2Service.getOAuthStateKey(state), - OAuthProvidersEnum.LOCAL, - 120 * 1000, + 120_000, ), ); - return [code, state]; + return code; } - public async token(dto: TokenDto): Promise { + public async token(code: string): Promise { + const codeKey = Oauth2Service.getOAuthCodeKey(code); const email = await this.commonService.throwInternalError( - this.cacheManager.get(Oauth2Service.getOAuthCodeKey(dto.code)), + this.cacheManager.get(codeKey), ); if (!email) { throw new UnauthorizedException('Code is invalid or expired'); } - const provider = await this.commonService.throwInternalError( - this.cacheManager.get( - Oauth2Service.getOAuthStateKey(dto.state), - ), - ); - - if (!provider || provider !== OAuthProvidersEnum.LOCAL) { - throw new UnauthorizedException('Corrupted state'); - } + await this.commonService.throwInternalError(this.cacheManager.del(codeKey)); const user = await this.usersService.findOneByEmail(email); const [accessToken, refreshToken] = await this.jwtService.generateAuthTokens(user); - return { user, accessToken, refreshToken }; + return { + user, + accessToken, + refreshToken, + expiresIn: this.jwtService.accessTime, + }; } private getOAuth(provider: OAuthProvidersEnum): OAuthClass { @@ -234,6 +227,8 @@ export class Oauth2Service { throw new UnauthorizedException('Corrupted state'); } - return await this.commonService.throwInternalError(oauth.getToken(code)); + return await this.commonService.throwUnauthorizedError( + oauth.getToken(code), + ); } } diff --git a/src/oauth2/tests/oauth2.service.spec.ts b/src/oauth2/tests/oauth2.service.spec.ts index a022180..fc29eac 100644 --- a/src/oauth2/tests/oauth2.service.spec.ts +++ b/src/oauth2/tests/oauth2.service.spec.ts @@ -20,15 +20,16 @@ import { MikroORM } from '@mikro-orm/core'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { HttpModule } from '@nestjs/axios'; import { CacheModule } from '@nestjs/cache-manager'; +import { UnauthorizedException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { isJWT } from 'class-validator'; import { CommonModule } from '../../common/common.module'; import { CommonService } from '../../common/common.service'; import { config } from '../../config'; import { validationSchema } from '../../config/config.schema'; import { MikroOrmConfig } from '../../config/mikroorm.config'; import { JwtModule } from '../../jwt/jwt.module'; +import { UserEntity } from '../../users/entities/user.entity'; import { OAuthProvidersEnum } from '../../users/enums/oauth-providers.enum'; import { UsersModule } from '../../users/users.module'; import { UsersService } from '../../users/users.service'; @@ -116,26 +117,22 @@ describe('Oauth2Service', () => { }); }); - describe('login', () => { - const email = faker.internet.email(); - const name = faker.name.fullName(); - + describe('callback', () => { it('should create a new user', async () => { - const [accessToken, refreshToken] = await oauth2Service.callback( + const email = faker.internet.email(); + const name = faker.person.fullName(); + const code = await oauth2Service.callback( OAuthProvidersEnum.GOOGLE, email, name, ); - expect(accessToken).toBeDefined(); - expect(refreshToken).toBeDefined(); - expect(isJWT(accessToken)).toBeTruthy(); - expect(isJWT(refreshToken)).toBeTruthy(); + expect(code).toBeDefined(); + expect(code.length).toBe(22); const user = await usersService.findOneByEmail(email); expect(user).toBeDefined(); expect(user.confirmed).toBeTruthy(); - expect(user.id).toStrictEqual(1); const providers = await usersService.findOAuthProviders(user.id); expect(providers).toBeDefined(); @@ -144,21 +141,21 @@ describe('Oauth2Service', () => { }); it('should login an existing user', async () => { - const [accessToken, refreshToken] = await oauth2Service.callback( + const email = faker.internet.email(); + const name = faker.person.fullName(); + await usersService.create(OAuthProvidersEnum.GOOGLE, email, name); + const code = await oauth2Service.callback( OAuthProvidersEnum.MICROSOFT, email, name, ); - expect(accessToken).toBeDefined(); - expect(refreshToken).toBeDefined(); - expect(isJWT(accessToken)).toBeTruthy(); - expect(isJWT(refreshToken)).toBeTruthy(); + expect(code).toBeDefined(); + expect(code.length).toBe(22); const user = await usersService.findOneByEmail(email); expect(user).toBeDefined(); expect(user.confirmed).toBeTruthy(); - expect(user.id).toStrictEqual(1); const providers = await usersService.findOAuthProviders(user.id); expect(providers).toBeDefined(); expect(providers).toHaveLength(2); @@ -167,6 +164,34 @@ describe('Oauth2Service', () => { }); }); + describe('token', () => { + it('should return access and refresh tokens from callback code', async () => { + const email = faker.internet.email(); + const name = faker.person.fullName(); + const code = await oauth2Service.callback( + OAuthProvidersEnum.MICROSOFT, + email, + name, + ); + + const result = await oauth2Service.token(code); + + expect(result).toMatchObject({ + user: expect.any(UserEntity), + accessToken: expect.any(String), + refreshToken: expect.any(String), + }); + }); + + it('should throw an unauthorized exception for invalid callback code', async () => { + const code = '7IHq0AGB7FOL25kt8WejRz'; + + await expect(oauth2Service.token(code)).rejects.toThrow( + new UnauthorizedException('Code is invalid or expired'), + ); + }); + }); + afterAll(async () => { await orm.getSchemaGenerator().dropSchema(); await orm.close(true); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 2e77728..85cc10e 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -72,7 +72,7 @@ describe('AppController (e2e)', () => { await app.getHttpAdapter().getInstance().ready(); }); - const name = faker.name.firstName(); + const name = faker.person.firstName(); const email = faker.internet.email().toLowerCase(); const password = faker.internet.password(10) + 'A1!'; const mockUser = { @@ -282,7 +282,7 @@ describe('AppController (e2e)', () => { }); it('should throw 401 error if user is not confirmed', async () => { - const newName = faker.name.firstName(); + const newName = faker.person.firstName(); await usersService.create( OAuthProvidersEnum.LOCAL, newEmail, @@ -350,7 +350,7 @@ describe('AppController (e2e)', () => { .expect(HttpStatus.UNAUTHORIZED); }); - it('should logout the user', async () => { + it('should logout the user with cookie', async () => { const signInRes = await request(app.getHttpServer()) .post(`${baseUrl}/sign-in`) .send({ @@ -365,6 +365,17 @@ describe('AppController (e2e)', () => { .set('Cookie', signInRes.header['set-cookie']) .expect(HttpStatus.OK); }); + + it('should logout the user with refresh bodie', async () => { + const user = await usersService.findOneByEmail(email); + const [accessToken, refreshToken] = + await jwtService.generateAuthTokens(user); + return request(app.getHttpServer()) + .post(logoutUrl) + .set('Authorization', `Bearer ${accessToken}`) + .send({ refreshToken }) + .expect(HttpStatus.OK); + }); }); describe('forgot-password', () => { @@ -711,7 +722,7 @@ describe('AppController (e2e)', () => { }); it('update name', async () => { - const newName = faker.name.firstName(); + const newName = faker.person.firstName(); const response = await request(app.getHttpServer()) .patch(baseUrl) .set('Authorization', `Bearer ${signInRes.body.accessToken}`) @@ -732,7 +743,7 @@ describe('AppController (e2e)', () => { it('update username', async () => { const newUsername = commonService.generatePointSlug( - faker.name.firstName(), + faker.person.firstName(), ); const response = await request(app.getHttpServer()) .patch(baseUrl) diff --git a/test/oauth2.e2e-spec.ts b/test/oauth2.e2e-spec.ts new file mode 100644 index 0000000..a926e42 --- /dev/null +++ b/test/oauth2.e2e-spec.ts @@ -0,0 +1,279 @@ +/* + Copyright (C) 2024 Afonso Barracha + + Nest OAuth is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Nest OAuth is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with Nest OAuth. If not, see . +*/ + +import fastifyCookie from '@fastify/cookie'; +import { HttpStatus, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + FastifyAdapter, + NestFastifyApplication, +} from '@nestjs/platform-fastify'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { OAuthProvidersEnum } from '../src/users/enums/oauth-providers.enum'; +import { Cache } from 'cache-manager'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Oauth2Service } from '../src/oauth2/oauth2.service'; +import nock from 'nock'; +import { faker } from '@faker-js/faker'; +import { CommonService } from '../src/common/common.service'; + +const URLS = { + [OAuthProvidersEnum.MICROSOFT]: { + authorizeHost: 'https://login.microsoftonline.com', + authorizePath: '/common/oauth2/v2.0/authorize', + tokenHost: 'https://login.microsoftonline.com', + tokenPath: '/common/oauth2/v2.0/token', + userUrl: 'https://graph.microsoft.com/v1.0/me', + }, + [OAuthProvidersEnum.GOOGLE]: { + authorizeHost: 'https://accounts.google.com', + authorizePath: '/o/oauth2/v2/auth', + tokenHost: 'https://www.googleapis.com', + tokenPath: '/oauth2/v4/token', + userUrl: 'https://www.googleapis.com/oauth2/v3/userinfo', + }, + [OAuthProvidersEnum.GITHUB]: { + authorizeHost: 'https://github.com', + authorizePath: '/login/oauth/authorize', + tokenHost: 'https://github.com', + tokenPath: '/login/oauth/access_token', + userUrl: 'https://api.github.com/user', + }, +}; + +describe('OAuth2 (e2e)', () => { + let app: NestFastifyApplication, + configService: ConfigService, + cacheManager: Cache, + oauth2Service: Oauth2Service, + commonService: CommonService; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication( + new FastifyAdapter(), + ); + + configService = app.get(ConfigService); + cacheManager = app.get(CACHE_MANAGER); + oauth2Service = app.get(Oauth2Service); + commonService = app.get(CommonService); + + await app.register(fastifyCookie, { + secret: configService.get('COOKIE_SECRET'), + }); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + }), + ); + + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + const baseUrl = '/api/auth/ext'; + + describe.each([ + OAuthProvidersEnum.GOOGLE, + OAuthProvidersEnum.GITHUB, + OAuthProvidersEnum.MICROSOFT, + ])(`%s`, (provider) => { + describe(`GET /api/auth/ext/${provider}`, () => { + it('should return 307 temporary redirect', async () => { + const authorizationUrl = `${baseUrl}/${provider}`; + const redirectUrl = + URLS[provider].authorizeHost + URLS[provider].authorizePath; + + return request(app.getHttpServer()) + .get(authorizationUrl) + .expect(HttpStatus.TEMPORARY_REDIRECT) + .expect((res) => { + expect(res.headers.location.startsWith(redirectUrl)).toBe(true); + }); + }); + }); + + describe(`GET /api/auth/ext/${provider}/callback`, () => { + const callbackPath = `${baseUrl}/${provider}/callback`; + const state = '6618ff2967f04817a905a345d288d12d'; + const code = '0mQzI6CUWhzMm33dszX9et'; + const accessToken = 'some-access-token'; + const refreshToken = 'some-refresh-token'; + const host = URLS[provider].tokenHost; + const path = URLS[provider].tokenPath; + const userUrl = URLS[provider].userUrl; + + const name = faker.person.fullName(); + const email = faker.internet.email().toLowerCase(); + + it('should return 202 accepted and redirect with code', async () => { + const frontendUrl = `https://${configService.get('domain')}/callback?code=`; + await cacheManager.set(`oauth_state:${state}`, provider, 120_000); + const tokenScope = nock(host, { + reqheaders: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }, + }) + .post( + path, + (body) => + body.grant_type === 'authorization_code' && + body.code === code && + body.redirect_uri.includes(callbackPath), + ) + .reply(200, { + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: refreshToken, + }); + const userScope = nock(userUrl, { + reqheaders: { + accept: 'application/json', + authorization: `Bearer ${accessToken}`, + }, + }) + .get('') + .reply( + 200, + provider === OAuthProvidersEnum.MICROSOFT + ? { + displayName: name, + mail: email, + } + : { + name, + email, + }, + ); + + await request(app.getHttpServer()) + .get(`${callbackPath}?code=${code}&state=${state}`) + .expect(HttpStatus.ACCEPTED) + .expect((res) => { + expect(res.headers.location.startsWith(frontendUrl)).toBe(true); + expect(res.headers.location.split('?code=')[1].length).toBe(22); + }); + + expect(tokenScope.isDone()).toBe(true); + expect(userScope.isDone()).toBe(true); + }); + + it('should return 401 unauthorized when the code is wrong', async () => { + await cacheManager.set(`oauth_state:${state}`, provider, 120_000); + const tokenScope = nock(host, { + reqheaders: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }, + }) + .post( + path, + (body) => + body.grant_type === 'authorization_code' && + body.code === code && + body.redirect_uri.includes(callbackPath), + ) + .reply(401, { code: 'Unauthorized' }); + + await request(app.getHttpServer()) + .get(`${callbackPath}?code=${code}&state=${state}`) + .expect(HttpStatus.UNAUTHORIZED); + + expect(tokenScope.isDone()).toBe(true); + }); + + it('should return 401 unauthorized when the state is expired or non-existent', async () => { + await cacheManager.del(`oauth_state:${state}`); + const tokenScope = nock(host, { + reqheaders: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }, + }) + .post( + path, + (body) => + body.grant_type === 'authorization_code' && + body.code === code && + body.redirect_uri.includes(callbackPath), + ) + .reply(200); + + await request(app.getHttpServer()) + .get(`${callbackPath}?code=${code}&state=${state}`) + .expect(HttpStatus.UNAUTHORIZED); + + expect(tokenScope.isDone()).toBe(false); + }); + }); + + describe('POST /api/auth/ext/token', () => { + const tokenPath = `${baseUrl}/token`; + const name = faker.person.fullName(); + const email = faker.internet.email().toLowerCase(); + + it('should return 200 OK with access and refresh token', async () => { + const code = await oauth2Service.callback(provider, email, name); + + return request(app.getHttpServer()) + .post(tokenPath) + .send({ code }) + .expect(HttpStatus.OK) + .expect((res) => { + expect(res.body).toMatchObject({ + accessToken: expect.any(String), + refreshToken: expect.any(String), + expiresIn: expect.any(Number), + tokenType: 'Bearer', + user: { + id: expect.any(Number), + name: commonService.formatName(name), + username: commonService.generatePointSlug(name), + email, + }, + }); + }); + }); + + it('should return 401 UNAUTHORIZED when the code is expired', async () => { + const code = await oauth2Service.callback(provider, email, name); + await cacheManager.del(`oauth_code:${code}`); + + return request(app.getHttpServer()) + .post(tokenPath) + .send({ code }) + .expect(HttpStatus.UNAUTHORIZED); + }); + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 6091d1b..5677de5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4412,30 +4412,6 @@ __metadata: languageName: node linkType: hard -"fastify@npm:4.28.0": - version: 4.28.0 - resolution: "fastify@npm:4.28.0" - dependencies: - "@fastify/ajv-compiler": ^3.5.0 - "@fastify/error": ^3.4.0 - "@fastify/fast-json-stringify-compiler": ^4.3.0 - abstract-logging: ^2.0.1 - avvio: ^8.3.0 - fast-content-type-parse: ^1.1.0 - fast-json-stringify: ^5.8.0 - find-my-way: ^8.0.0 - light-my-request: ^5.11.0 - pino: ^9.0.0 - process-warning: ^3.0.0 - proxy-addr: ^2.0.7 - rfdc: ^1.3.0 - secure-json-parse: ^2.7.0 - semver: ^7.5.4 - toad-cache: ^3.3.0 - checksum: 36944ecd39fb0ea364cfbbf2d2df7caf5979d1c663e0f4ae91f6aa4de37fd9909bce26aea724cc313e26632294b78669954b965ea9678defc22907a535afb565 - languageName: node - linkType: hard - "fastify@npm:4.28.1": version: 4.28.1 resolution: "fastify@npm:4.28.1" @@ -6173,6 +6149,13 @@ __metadata: languageName: node linkType: hard +"json-stringify-safe@npm:^5.0.1": + version: 5.0.1 + resolution: "json-stringify-safe@npm:5.0.1" + checksum: 48ec0adad5280b8a96bb93f4563aa1667fd7a36334f79149abd42446d0989f2ddc58274b479f4819f1f00617957e6344c886c55d05a4e15ebb4ab931e4a6a8ee + languageName: node + linkType: hard + "json5@npm:^2.2.2, json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" @@ -6957,13 +6940,14 @@ __metadata: eslint-config-prettier: 8.10.0 eslint-plugin-header: 3.1.1 eslint-plugin-prettier: 5.2.1 - fastify: 4.28.0 + fastify: 4.28.1 handlebars: 4.7.8 ioredis: 5.4.1 jest: 29.7.0 joi: 17.13.3 jsonwebtoken: 9.0.2 nestjs-throttler-storage-redis: 0.5.0 + nock: 13.5.5 nodemailer: 6.9.14 prettier: 3.3.3 reflect-metadata: 0.2.2 @@ -6994,6 +6978,17 @@ __metadata: languageName: node linkType: hard +"nock@npm:13.5.5": + version: 13.5.5 + resolution: "nock@npm:13.5.5" + dependencies: + debug: ^4.1.0 + json-stringify-safe: ^5.0.1 + propagate: ^2.0.0 + checksum: 91947b683992096a694140714323f11493b8ad9961c172e3e574c4801131fea259755e95c48e7e01527c14209967c20f151ff03b6bf6700471f0f76fa4071d32 + languageName: node + linkType: hard + "node-abi@npm:^3.3.0": version: 3.65.0 resolution: "node-abi@npm:3.65.0" @@ -7763,6 +7758,13 @@ __metadata: languageName: node linkType: hard +"propagate@npm:^2.0.0": + version: 2.0.1 + resolution: "propagate@npm:2.0.1" + checksum: c4febaee2be0979e82fb6b3727878fd122a98d64a7fa3c9d09b0576751b88514a9e9275b1b92e76b364d488f508e223bd7e1dcdc616be4cdda876072fbc2a96c + languageName: node + linkType: hard + "proxy-addr@npm:^2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" From 1d378a0fd0ba3808660cdeb888723b54e72b7182 Mon Sep 17 00:00:00 2001 From: Afonso Barracha Date: Sat, 24 Aug 2024 14:29:56 +1200 Subject: [PATCH 3/5] refactor(oauth2): make token endpoint private --- src/auth/auth.controller.ts | 3 +- src/oauth2/dtos/token.dto.ts | 11 ++- .../interfaces/callback-result.interface.ts | 22 ++++++ src/oauth2/oauth2.controller.ts | 25 +++++-- src/oauth2/oauth2.service.ts | 24 +++++-- src/oauth2/tests/oauth2.service.spec.ts | 43 +++++++---- test/app.e2e-spec.ts | 71 ++++++++++++++++++- test/oauth2.e2e-spec.ts | 61 ++++++++++++++-- 8 files changed, 228 insertions(+), 32 deletions(-) create mode 100644 src/oauth2/interfaces/callback-result.interface.ts diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index cf02e64..5552fa1 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -163,8 +163,9 @@ export class AuthController { public async logout( @Req() req: FastifyRequest, @Res() res: FastifyReply, + @Body() refreshAccessDto?: RefreshAccessDto, ): Promise { - const token = this.refreshTokenFromReq(req); + const token = this.refreshTokenFromReq(req, refreshAccessDto); const message = await this.authService.logout(token); res .clearCookie(this.cookieName, { path: this.cookiePath }) diff --git a/src/oauth2/dtos/token.dto.ts b/src/oauth2/dtos/token.dto.ts index 3d5a1d0..ac65246 100644 --- a/src/oauth2/dtos/token.dto.ts +++ b/src/oauth2/dtos/token.dto.ts @@ -16,7 +16,7 @@ */ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, Length } from 'class-validator'; +import { IsString, IsUrl, Length } from 'class-validator'; export abstract class TokenDto { @ApiProperty({ @@ -29,4 +29,13 @@ export abstract class TokenDto { @IsString() @Length(1, 22) public code: string; + + @ApiProperty({ + description: 'Redirect URI that was used to get the token', + example: 'https://example.com/auth/callback', + type: String, + }) + @IsString() + @IsUrl() + public redirectUri: string; } diff --git a/src/oauth2/interfaces/callback-result.interface.ts b/src/oauth2/interfaces/callback-result.interface.ts new file mode 100644 index 0000000..f3437aa --- /dev/null +++ b/src/oauth2/interfaces/callback-result.interface.ts @@ -0,0 +1,22 @@ +/* + Copyright (C) 2024 Afonso Barracha + + Nest OAuth is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Nest OAuth is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with Nest OAuth. If not, see . +*/ + +export interface ICallbackResult { + readonly code: string; + readonly accessToken: string; + readonly expiresIn: number; +} diff --git a/src/oauth2/oauth2.controller.ts b/src/oauth2/oauth2.controller.ts index 0915946..087c8e5 100644 --- a/src/oauth2/oauth2.controller.ts +++ b/src/oauth2/oauth2.controller.ts @@ -23,6 +23,7 @@ import { Post, Query, Res, + UnauthorizedException, UseGuards, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -47,6 +48,7 @@ import { IMicrosoftUser, } from './interfaces/user-response.interface'; import { Oauth2Service } from './oauth2.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; @ApiTags('Oauth2') @Controller('api/auth/ext') @@ -210,7 +212,6 @@ export class Oauth2Controller { return this.callbackAndRedirect(res, provider, email, name); } - @Public() @Post('token') @ApiResponse({ description: "Returns the user's OAuth 2 response", @@ -220,10 +221,15 @@ export class Oauth2Controller { description: 'Code or state is invalid', }) public async token( + @CurrentUser() userId: number, @Body() tokenDto: TokenDto, @Res() res: FastifyReply, ): Promise { - const result = await this.oauth2Service.token(tokenDto.code); + if (tokenDto.redirectUri !== this.url + '/auth/callback') { + throw new UnauthorizedException(); + } + + const result = await this.oauth2Service.token(tokenDto.code, userId); return res .cookie(this.cookieName, result.refreshToken, { secure: !this.testing, @@ -252,9 +258,20 @@ export class Oauth2Controller { email: string, name: string, ): Promise { - const code = await this.oauth2Service.callback(provider, email, name); + const { code, accessToken, expiresIn } = await this.oauth2Service.callback( + provider, + email, + name, + ); + const urlSearchParams = new URLSearchParams({ + code, + accessToken, + tokenType: 'Bearer', + expiresIn: expiresIn.toString(), + }); + return res .status(HttpStatus.ACCEPTED) - .redirect(`${this.url}/callback?code=${code}`); + .redirect(`${this.url}/auth/callback?${urlSearchParams.toString()}`); } } diff --git a/src/oauth2/oauth2.service.ts b/src/oauth2/oauth2.service.ts index e6043e9..746b31d 100644 --- a/src/oauth2/oauth2.service.ts +++ b/src/oauth2/oauth2.service.ts @@ -38,6 +38,8 @@ import { UsersService } from '../users/users.service'; import { OAuthClass } from './classes/oauth.class'; import { ICallbackQuery } from './interfaces/callback-query.interface'; import { IClient } from './interfaces/client.interface'; +import { TokenTypeEnum } from '../jwt/enums/token-type.enum'; +import { ICallbackResult } from './interfaces/callback-result.interface'; @Injectable() export class Oauth2Service { @@ -165,7 +167,7 @@ export class Oauth2Service { provider: OAuthProvidersEnum, email: string, name: string, - ): Promise { + ): Promise { const user = await this.usersService.findOrCreate(provider, email, name); const code = Oauth2Service.generateCode(); @@ -177,22 +179,34 @@ export class Oauth2Service { ), ); - return code; + const accessToken = await this.jwtService.generateToken( + user, + TokenTypeEnum.ACCESS, + ); + return { + code, + accessToken, + expiresIn: this.jwtService.accessTime, + }; } - public async token(code: string): Promise { + public async token(code: string, userId: number): Promise { const codeKey = Oauth2Service.getOAuthCodeKey(code); const email = await this.commonService.throwInternalError( this.cacheManager.get(codeKey), ); if (!email) { - throw new UnauthorizedException('Code is invalid or expired'); + throw new UnauthorizedException(); } await this.commonService.throwInternalError(this.cacheManager.del(codeKey)); - const user = await this.usersService.findOneByEmail(email); + + if (user.id !== userId) { + throw new UnauthorizedException(); + } + const [accessToken, refreshToken] = await this.jwtService.generateAuthTokens(user); return { diff --git a/src/oauth2/tests/oauth2.service.spec.ts b/src/oauth2/tests/oauth2.service.spec.ts index fc29eac..2af6b7a 100644 --- a/src/oauth2/tests/oauth2.service.spec.ts +++ b/src/oauth2/tests/oauth2.service.spec.ts @@ -34,6 +34,7 @@ import { OAuthProvidersEnum } from '../../users/enums/oauth-providers.enum'; import { UsersModule } from '../../users/users.module'; import { UsersService } from '../../users/users.service'; import { Oauth2Service } from '../oauth2.service'; +import { isJWT } from 'class-validator'; describe('Oauth2Service', () => { let module: TestingModule, @@ -121,14 +122,19 @@ describe('Oauth2Service', () => { it('should create a new user', async () => { const email = faker.internet.email(); const name = faker.person.fullName(); - const code = await oauth2Service.callback( + const result = await oauth2Service.callback( OAuthProvidersEnum.GOOGLE, email, name, ); - expect(code).toBeDefined(); - expect(code.length).toBe(22); + expect(result).toMatchObject({ + accessToken: expect.any(String), + code: expect.any(String), + expiresIn: expect.any(Number), + }); + expect(isJWT(result.accessToken)).toBe(true); + expect(result.code).toHaveLength(22); const user = await usersService.findOneByEmail(email); expect(user).toBeDefined(); @@ -144,14 +150,19 @@ describe('Oauth2Service', () => { const email = faker.internet.email(); const name = faker.person.fullName(); await usersService.create(OAuthProvidersEnum.GOOGLE, email, name); - const code = await oauth2Service.callback( + const result = await oauth2Service.callback( OAuthProvidersEnum.MICROSOFT, email, name, ); - expect(code).toBeDefined(); - expect(code.length).toBe(22); + expect(result).toMatchObject({ + accessToken: expect.any(String), + code: expect.any(String), + expiresIn: expect.any(Number), + }); + expect(isJWT(result.accessToken)).toBe(true); + expect(result.code).toHaveLength(22); const user = await usersService.findOneByEmail(email); expect(user).toBeDefined(); @@ -166,15 +177,16 @@ describe('Oauth2Service', () => { describe('token', () => { it('should return access and refresh tokens from callback code', async () => { - const email = faker.internet.email(); + const email = faker.internet.email().toLowerCase(); const name = faker.person.fullName(); - const code = await oauth2Service.callback( - OAuthProvidersEnum.MICROSOFT, + const { code } = await oauth2Service.callback( + OAuthProvidersEnum.GOOGLE, email, name, ); + const user = await usersService.findOneByEmail(email); - const result = await oauth2Service.token(code); + const result = await oauth2Service.token(code, user.id); expect(result).toMatchObject({ user: expect.any(UserEntity), @@ -184,11 +196,14 @@ describe('Oauth2Service', () => { }); it('should throw an unauthorized exception for invalid callback code', async () => { - const code = '7IHq0AGB7FOL25kt8WejRz'; + const email = faker.internet.email().toLowerCase(); + const name = faker.person.fullName(); + await oauth2Service.callback(OAuthProvidersEnum.MICROSOFT, email, name); + const user = await usersService.findOneByEmail(email); - await expect(oauth2Service.token(code)).rejects.toThrow( - new UnauthorizedException('Code is invalid or expired'), - ); + await expect( + oauth2Service.token('7IHq0AGB7FOL25kt8WejRz', user.id), + ).rejects.toThrow(new UnauthorizedException()); }); }); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 85cc10e..44e4e81 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -366,7 +366,7 @@ describe('AppController (e2e)', () => { .expect(HttpStatus.OK); }); - it('should logout the user with refresh bodie', async () => { + it('should logout the user with refresh body', async () => { const user = await usersService.findOneByEmail(email); const [accessToken, refreshToken] = await jwtService.generateAuthTokens(user); @@ -554,6 +554,75 @@ describe('AppController (e2e)', () => { }); }); }); + + describe('refresh-access', () => { + const refreshPath = `${baseUrl}/refresh-access`; + + it('should return 200 OK with auth response with the refresh token in the cookies', async () => { + const signInRes = await request(app.getHttpServer()) + .post(`${baseUrl}/sign-in`) + .send({ + emailOrUsername: email, + password, + }) + .expect(HttpStatus.OK); + + return request(app.getHttpServer()) + .post(refreshPath) + .set('Authorization', `Bearer ${signInRes.body.accessToken}`) + .set('Cookie', signInRes.header['set-cookie']) + .expect(HttpStatus.OK) + .expect((res) => { + expect(res.body).toMatchObject({ + accessToken: expect.any(String), + refreshToken: expect.any(String), + expiresIn: expect.any(Number), + tokenType: 'Bearer', + user: { + id: expect.any(Number), + name: commonService.formatName(name), + username: commonService.generatePointSlug(name), + email, + }, + }); + }); + }); + + it('should return 200 OK with auth response with the refresh token in the body', async () => { + const user = await usersService.findOneByEmail(email); + const [accessToken, refreshToken] = + await jwtService.generateAuthTokens(user); + return request(app.getHttpServer()) + .post(refreshPath) + .set('Authorization', `Bearer ${accessToken}`) + .send({ refreshToken }) + .expect(HttpStatus.OK) + .expect((res) => { + expect(res.body).toMatchObject({ + accessToken: expect.any(String), + refreshToken: expect.any(String), + expiresIn: expect.any(Number), + tokenType: 'Bearer', + user: { + id: expect.any(Number), + name: commonService.formatName(name), + username: commonService.generatePointSlug(name), + email, + }, + }); + }); + }); + + it('should return 401 UNAUTHORIZED when refresh token is not passed', async () => { + const user = await usersService.findOneByEmail(email); + const [accessToken] = await jwtService.generateAuthTokens(user); + + return request(app.getHttpServer()) + .post(refreshPath) + .set('Authorization', `Bearer ${accessToken}`) + .expect(HttpStatus.UNAUTHORIZED); + }); + }); }); describe('api/users', () => { diff --git a/test/oauth2.e2e-spec.ts b/test/oauth2.e2e-spec.ts index a926e42..b13c6d9 100644 --- a/test/oauth2.e2e-spec.ts +++ b/test/oauth2.e2e-spec.ts @@ -32,6 +32,7 @@ import { Oauth2Service } from '../src/oauth2/oauth2.service'; import nock from 'nock'; import { faker } from '@faker-js/faker'; import { CommonService } from '../src/common/common.service'; +import { isJWT } from 'class-validator'; const URLS = { [OAuthProvidersEnum.MICROSOFT]: { @@ -127,7 +128,7 @@ describe('OAuth2 (e2e)', () => { const email = faker.internet.email().toLowerCase(); it('should return 202 accepted and redirect with code', async () => { - const frontendUrl = `https://${configService.get('domain')}/callback?code=`; + const frontendUrl = `https://${configService.get('domain')}/auth/callback`; await cacheManager.set(`oauth_state:${state}`, provider, 120_000); const tokenScope = nock(host, { reqheaders: { @@ -173,7 +174,14 @@ describe('OAuth2 (e2e)', () => { .expect(HttpStatus.ACCEPTED) .expect((res) => { expect(res.headers.location.startsWith(frontendUrl)).toBe(true); - expect(res.headers.location.split('?code=')[1].length).toBe(22); + + const queryParams = res.headers.location.split('?')[1]; + const searchParams = new URLSearchParams(queryParams); + + expect(searchParams.get('code')).toHaveLength(22); + expect(isJWT(searchParams.get('accessToken'))).toBe(true); + expect(searchParams.get('tokenType')).toBe('Bearer'); + expect(searchParams.has('expiresIn')).toBe(true); }); expect(tokenScope.isDone()).toBe(true); @@ -235,11 +243,18 @@ describe('OAuth2 (e2e)', () => { const email = faker.internet.email().toLowerCase(); it('should return 200 OK with access and refresh token', async () => { - const code = await oauth2Service.callback(provider, email, name); + const redirectUri = `https://${configService.get('domain')}/auth/callback`; + const { accessToken, code } = await oauth2Service.callback( + provider, + email, + name, + ); return request(app.getHttpServer()) .post(tokenPath) - .send({ code }) + .set('Authorization', `Bearer ${accessToken}`) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send(new URLSearchParams({ code, redirectUri }).toString()) .expect(HttpStatus.OK) .expect((res) => { expect(res.body).toMatchObject({ @@ -258,12 +273,46 @@ describe('OAuth2 (e2e)', () => { }); it('should return 401 UNAUTHORIZED when the code is expired', async () => { - const code = await oauth2Service.callback(provider, email, name); + const redirectUri = `https://${configService.get('domain')}/auth/callback`; + const { accessToken, code } = await oauth2Service.callback( + provider, + email, + name, + ); await cacheManager.del(`oauth_code:${code}`); return request(app.getHttpServer()) .post(tokenPath) - .send({ code }) + .set('Authorization', `Bearer ${accessToken}`) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send(new URLSearchParams({ code, redirectUri }).toString()) + .expect(HttpStatus.UNAUTHORIZED); + }); + + it('should return 401 UNAUTHORIZED when the user is not authenticated', async () => { + const redirectUri = `https://${configService.get('domain')}/auth/callback`; + const { code } = await oauth2Service.callback(provider, email, name); + + return request(app.getHttpServer()) + .post(tokenPath) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send(new URLSearchParams({ code, redirectUri }).toString()) + .expect(HttpStatus.UNAUTHORIZED); + }); + + it('should return 401 UNAUTHORIZED when the redirectUri is wrong', async () => { + const redirectUri = `https://not-the-correct-url.com/auth/callback`; + const { accessToken, code } = await oauth2Service.callback( + provider, + email, + name, + ); + + return request(app.getHttpServer()) + .post(tokenPath) + .set('Authorization', `Bearer ${accessToken}`) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send(new URLSearchParams({ code, redirectUri }).toString()) .expect(HttpStatus.UNAUTHORIZED); }); }); From ebffd604022d9504796af4a0b7921001743a7fc7 Mon Sep 17 00:00:00 2001 From: Afonso Barracha Date: Sat, 24 Aug 2024 14:42:21 +1200 Subject: [PATCH 4/5] fix(oauth2): make internal code ttl the same as access token --- src/oauth2/oauth2.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oauth2/oauth2.service.ts b/src/oauth2/oauth2.service.ts index 746b31d..701151d 100644 --- a/src/oauth2/oauth2.service.ts +++ b/src/oauth2/oauth2.service.ts @@ -175,7 +175,7 @@ export class Oauth2Service { this.cacheManager.set( Oauth2Service.getOAuthCodeKey(code), user.email, - 120_000, + this.jwtService.accessTime * 1000, ), ); From a0face7aa01fb1c2e0cf7b4a6deec9591ffe2386 Mon Sep 17 00:00:00 2001 From: Afonso Barracha Date: Sat, 24 Aug 2024 15:58:13 +1200 Subject: [PATCH 5/5] chore: update REAME.md --- README.md | 4 ++-- src/oauth2/oauth2.controller.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1be2694..87ccc6b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# Nest OAuth: Adding External Providers +# Nest OAuth: Adding Mobile Apps Support ## Intro This is the source code for the -tutorial [Nest Authentication with OAuth2.0](https://dev.to/tugascript/nestjs-authentication-with-oauth20-adding-external-providers-2kj). +tutorial [Nest Authentication with OAuth2.0](https://dev.to/tugascript/nestjs-authentication-with-oauth20-adding-mobile-apps-support-13nl). This is the 6th and extra part on a 5 part series, where we will build a production level NestJS OAuth2 service. diff --git a/src/oauth2/oauth2.controller.ts b/src/oauth2/oauth2.controller.ts index 087c8e5..a0b950a 100644 --- a/src/oauth2/oauth2.controller.ts +++ b/src/oauth2/oauth2.controller.ts @@ -218,7 +218,7 @@ export class Oauth2Controller { status: HttpStatus.OK, }) @ApiUnauthorizedResponse({ - description: 'Code or state is invalid', + description: 'Code or redirectUri is invalid', }) public async token( @CurrentUser() userId: number,