From be8392ddba6568429b05ea3eedf295bc7b4394f2 Mon Sep 17 00:00:00 2001 From: Alejandro Peralta Date: Thu, 16 Jan 2025 13:48:37 +0100 Subject: [PATCH] feat(api): Implement refresh token strategy --- .../modules/auth/authentication.controller.ts | 11 ++ .../modules/auth/authentication.service.ts | 43 ++++---- api/src/modules/auth/services/jwt.manager.ts | 100 +++++++++++++++--- api/src/modules/config/auth-config.handler.ts | 8 ++ api/src/utils/time.utils.ts | 19 ++++ .../integration/auth/refresh-token.spec.ts | 80 ++++++++++++++ .../integration/auth/validate-token.spec.ts | 9 +- api/test/utils/user.auth.ts | 14 ++- shared/config/.env.test | 4 + shared/contracts/auth.contract.ts | 14 ++- shared/dtos/auth-token-pair.dto.ts | 6 ++ shared/dtos/users/user.dto.ts | 7 +- shared/schemas/auth/refresh-token.schema.ts | 5 + shared/schemas/auth/token-type.schema.ts | 1 + 14 files changed, 279 insertions(+), 42 deletions(-) create mode 100644 api/src/utils/time.utils.ts create mode 100644 api/test/integration/auth/refresh-token.spec.ts create mode 100644 shared/dtos/auth-token-pair.dto.ts create mode 100644 shared/schemas/auth/refresh-token.schema.ts diff --git a/api/src/modules/auth/authentication.controller.ts b/api/src/modules/auth/authentication.controller.ts index b3bcea85..b5c8c652 100644 --- a/api/src/modules/auth/authentication.controller.ts +++ b/api/src/modules/auth/authentication.controller.ts @@ -36,6 +36,17 @@ export class AuthenticationController { private readonly configService: ApiConfigService, ) {} + @Public() + @TsRestHandler(authContract.refreshToken) + public async refreshToken(): Promise { + return tsRestHandler(authContract.refreshToken, async ({ body }) => { + return { + status: 200, + body: await this.authService.refreshAuthTokens(body.refreshToken), + }; + }); + } + @Public() @TsRestHandler(authContract.register) async register( diff --git a/api/src/modules/auth/authentication.service.ts b/api/src/modules/auth/authentication.service.ts index 8356038d..9920588d 100644 --- a/api/src/modules/auth/authentication.service.ts +++ b/api/src/modules/auth/authentication.service.ts @@ -10,7 +10,7 @@ import { UsersService } from '@api/modules/users/users.service'; import { User } from '@shared/entities/users/user.entity'; import * as bcrypt from 'bcrypt'; import { CommandBus, EventBus } from '@nestjs/cqrs'; -import { UserWithAccessToken } from '@shared/dtos/users/user.dto'; +import { UserWithAuthTokens } from '@shared/dtos/users/user.dto'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; import { CreateUserDto } from '@shared/dtos/users/create-user.dto'; import { randomBytes } from 'crypto'; @@ -30,6 +30,9 @@ import { } from '@shared/entities/users/backoffice-session'; import { ROLES } from '@shared/entities/users/roles.enum'; import { InjectRepository } from '@nestjs/typeorm'; +import { ApiConfigService } from '@api/modules/config/app-config.service'; +import { TimeUtils } from '@api/utils/time.utils'; +import { AuthTokenPair } from '@shared/dtos/auth-token-pair.dto'; @Injectable() export class AuthenticationService { @@ -42,7 +45,13 @@ export class AuthenticationService { private readonly passwordManager: PasswordManager, @InjectRepository(BackOfficeSession) private readonly backOfficeSessionRepository: Repository, + private readonly config: ApiConfigService, ) {} + + async refreshAuthTokens(refreshToken: string): Promise { + return this.jwtManager.refreshAuthTokens(refreshToken); + } + async validateUser(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); if (user?.isActive && (await bcrypt.compare(password, user.password))) { @@ -105,17 +114,14 @@ export class AuthenticationService { .where(`sess -> 'adminUser' ->> 'id' = :id`, { id: user.id }) .execute(); - const currentDate = new Date(); - const sessionExpirationDate = new Date( - Date.UTC( - currentDate.getUTCFullYear() + 1, - currentDate.getUTCMonth(), - currentDate.getUTCDate(), - currentDate.getUTCHours(), - currentDate.getUTCMinutes(), - currentDate.getUTCSeconds(), - ), - ); + // Same expiration time as the refresh token + const expiresInDuration = this.config.getJWTConfigByType( + TOKEN_TYPE_ENUM.REFRESH, + ).expiresIn; + const expiresInSeconds = + TimeUtils.parseDurationToSeconds(expiresInDuration); + const expiresAt = Date.now() + expiresInSeconds * 1000; + const backofficeSession: BackOfficeSession = { sid: await uid(24), sess: { @@ -123,6 +129,7 @@ export class AuthenticationService { secure: false, httpOnly: true, path: '/', + maxAge: expiresAt, }, adminUser: { id: user.id, @@ -135,24 +142,24 @@ export class AuthenticationService { accessToken, }, }, - expire: sessionExpirationDate, + expire: new Date(expiresAt), }; await this.backOfficeSessionRepository.insert(backofficeSession); return backofficeSession; } - async logIn(user: User): Promise<[UserWithAccessToken, BackOfficeSession?]> { - const { accessToken } = await this.jwtManager.signAccessToken(user.id); + async logIn(user: User): Promise<[UserWithAuthTokens, BackOfficeSession?]> { + const tokenPair = await this.jwtManager.createAuthTokenPair(user.id); if (user.role !== ROLES.ADMIN) { - return [{ user, accessToken }]; + return [{ user, ...tokenPair }]; } // An adminjs session needs to be created for the admin user const backofficeSession = await this.createBackOfficeSession( user, - accessToken, + tokenPair.accessToken, ); - return [{ user, accessToken }, backofficeSession]; + return [{ user, ...tokenPair }, backofficeSession]; } async signUp(user: User, signUpDto: SignUpDto): Promise { diff --git a/api/src/modules/auth/services/jwt.manager.ts b/api/src/modules/auth/services/jwt.manager.ts index 22f1f68d..8ad3a3df 100644 --- a/api/src/modules/auth/services/jwt.manager.ts +++ b/api/src/modules/auth/services/jwt.manager.ts @@ -1,8 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ApiConfigService } from '@api/modules/config/app-config.service'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; import { UsersService } from '@api/modules/users/users.service'; +import { TimeUtils } from '@api/utils/time.utils'; +import { AuthTokenPair } from '@shared/dtos/auth-token-pair.dto'; @Injectable() export class JwtManager { @@ -12,30 +14,93 @@ export class JwtManager { private readonly users: UsersService, ) {} - private async sign( + public async refreshAuthTokens(refreshToken: string): Promise { + try { + const payload = await this.jwt.verifyAsync(refreshToken, { + secret: this.config.getJWTConfigByType(TOKEN_TYPE_ENUM.REFRESH).secret, + }); + + if ((await this.users.isUserActive(payload.sub)) === false) { + throw new UnauthorizedException('User is not active'); + } + return this.createAuthTokenPair(payload.id); + } catch (error) { + throw new UnauthorizedException('Invalid refresh token'); + } + } + + public async createAuthTokenPair(userId: string): Promise { + const [accessTokenData, refreshTokenData] = await Promise.all([ + this.signAccessToken(userId), + this.createRefreshToken(userId), + ]); + + return { + ...accessTokenData, + ...refreshTokenData, + }; + } + + public async signAccessToken( userId: string, - tokenType: TOKEN_TYPE_ENUM, - ): Promise<{ token: string; expiresIn: string }> { - const { secret, expiresIn } = this.config.getJWTConfigByType(tokenType); + ): Promise<{ accessToken: string; expiresAt: number }> { + const { secret, expiresIn } = this.config.getJWTConfigByType( + TOKEN_TYPE_ENUM.ACCESS, + ); + + const expiresInSeconds = TimeUtils.parseDurationToSeconds(expiresIn); + const expiresAt = Date.now() + expiresInSeconds * 1000; + const accessToken = await this.jwt.signAsync( + { + id: userId, // Potential breaking changes if we change this to sub? + aud: 'api', + iat: Math.floor(Date.now() / 1000), + // exp is set by the library + }, + { secret, algorithm: 'HS256', expiresIn: expiresInSeconds }, + ); + return { + accessToken, + expiresAt, + }; + } + + private async createRefreshToken( + userId: string, + ): Promise<{ refreshToken: string; refreshTokenExpiresAt: number }> { + const { secret, expiresIn } = this.config.getJWTConfigByType( + TOKEN_TYPE_ENUM.REFRESH, + ); + + const expiresInSeconds = TimeUtils.parseDurationToSeconds(expiresIn); + const expiresAt = Date.now() + expiresInSeconds * 1000; + const token = await this.jwt.signAsync( - { id: userId }, - { secret, expiresIn }, + { + sub: userId, + aud: 'api', + iat: Math.floor(Date.now() / 1000), + // exp is set by the library + }, + { secret, algorithm: 'HS256', expiresIn: expiresInSeconds }, ); return { - token, - expiresIn, + refreshToken: token, + refreshTokenExpiresAt: expiresAt, }; } - async signAccessToken( + private async sign( userId: string, - ): Promise<{ accessToken: string; expiresIn: string }> { - const { token: accessToken, expiresIn } = await this.sign( - userId, - TOKEN_TYPE_ENUM.ACCESS, + tokenType: TOKEN_TYPE_ENUM, + ): Promise<{ token: string; expiresIn: string }> { + const { secret, expiresIn } = this.config.getJWTConfigByType(tokenType); + const token = await this.jwt.signAsync( + { id: userId }, + { secret, expiresIn, algorithm: 'HS256' }, // Default algorithm ); return { - accessToken, + token, expiresIn, }; } @@ -82,7 +147,10 @@ export class JwtManager { async isTokenValid(token: string, type: TOKEN_TYPE_ENUM): Promise { const { secret } = this.config.getJWTConfigByType(type); try { - const { id } = await this.jwt.verifyAsync(token, { secret }); + const { id } = await this.jwt.verifyAsync(token, { + secret, + algorithms: ['HS256'], + }); switch (type) { case TOKEN_TYPE_ENUM.ACCOUNT_CONFIRMATION: /** diff --git a/api/src/modules/config/auth-config.handler.ts b/api/src/modules/config/auth-config.handler.ts index 2f1841e8..fa5b177e 100644 --- a/api/src/modules/config/auth-config.handler.ts +++ b/api/src/modules/config/auth-config.handler.ts @@ -19,6 +19,14 @@ export class JwtConfigHandler { ), }; + case TOKEN_TYPE_ENUM.REFRESH: + return { + secret: this.configService.getOrThrow('REFRESH_TOKEN_SECRET'), + expiresIn: this.configService.getOrThrow( + 'REFRESH_TOKEN_EXPIRES_IN', + ), + }; + case TOKEN_TYPE_ENUM.RESET_PASSWORD: return { secret: this.configService.getOrThrow( diff --git a/api/src/utils/time.utils.ts b/api/src/utils/time.utils.ts new file mode 100644 index 00000000..ea17efc8 --- /dev/null +++ b/api/src/utils/time.utils.ts @@ -0,0 +1,19 @@ +export const TimeUtils = { + parseDurationToSeconds: (duration: string): number => { + const timeValue = parseInt(duration.slice(0, -1), 10); // Get the numeric part + const timeUnit = duration.slice(-1); // Get the unit part (e.g., 'h', 'm', 's', 'd') + + switch (timeUnit) { + case 's': // Seconds + return timeValue; + case 'm': // Minutes + return timeValue * 60; + case 'h': // Hours + return timeValue * 3600; + case 'd': // Days + return timeValue * 86400; + default: + throw new Error('Invalid time unit. Use "s", "m", "h", or "d".'); + } + }, +} as const; diff --git a/api/test/integration/auth/refresh-token.spec.ts b/api/test/integration/auth/refresh-token.spec.ts new file mode 100644 index 00000000..aa02b00e --- /dev/null +++ b/api/test/integration/auth/refresh-token.spec.ts @@ -0,0 +1,80 @@ +import { authContract } from '@shared/contracts/auth.contract'; +import { ROLES } from '@shared/entities/users/roles.enum'; +import { User } from '@shared/entities/users/user.entity'; +import { TestManager } from 'api/test/utils/test-manager'; + +describe('Refresh token', () => { + let testManager: TestManager; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + }); + + afterEach(async () => { + await testManager.clearDatabase(); + }); + + afterAll(async () => { + await testManager.close(); + }); + + describe('AuthenticationController', () => { + it('should return a new token pair when the refresh token is valid', async () => { + // Given + const user = await testManager.mocks().createUser({ + email: 't@t.com', + isActive: true, + role: ROLES.PARTNER, + }); + + const { jwtToken: accessToken, refreshToken } = + await testManager.logUserIn(user); + + // When + const refreshRes = await testManager + .request() + .post(authContract.refreshToken.path) + .send({ refreshToken }); + + const newAccessToken = refreshRes.body.accessToken; + const newRefreshToken = refreshRes.body.refreshToken; + + // Then + expect(refreshRes.status).toBe(200); + expect(newAccessToken).not.toBe(accessToken); + expect(newRefreshToken).not.toBe(refreshToken); + }); + + it('should return a 401 status code when the refresh token sent is valid but the user is not found or inactive', async () => { + // Given + const user = await testManager.mocks().createUser({ + email: 'inactive@t.com', + isActive: true, + role: ROLES.PARTNER, + }); + + const { refreshToken } = await testManager.logUserIn(user); + + user.isActive = false; + await testManager.getDataSource().getRepository(User).save(user); + + // When + const refreshRes = await testManager + .request() + .post(authContract.refreshToken.path) + .send({ refreshToken }); + + // Then + expect(refreshRes.status).toBe(401); + }); + + it('should return a 401 status code when the refresh token sent is not valid', async () => { + const res = await testManager + .request() + .post(authContract.refreshToken.path) + .send({ refreshToken: 'fake_token' }); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/api/test/integration/auth/validate-token.spec.ts b/api/test/integration/auth/validate-token.spec.ts index 1ed84e64..d19ba0f4 100644 --- a/api/test/integration/auth/validate-token.spec.ts +++ b/api/test/integration/auth/validate-token.spec.ts @@ -59,12 +59,15 @@ describe('Validate Token', () => { // When an user with an expired token jest.spyOn(jwtConfigHandler, 'getJwtConfigByType').mockReturnValueOnce({ secret: configService.getOrThrow('RESET_PASSWORD_TOKEN_SECRET'), - expiresIn: '1ms', + expiresIn: '1s', }); const { resetPasswordToken } = await jwtManager.signResetPasswordToken('fake_id'); + // Sleep + await new Promise((resolve) => setTimeout(resolve, 1000)); + // When the users tries to validate the token with type "reset-password" const response = await testManager .request() @@ -174,10 +177,12 @@ describe('Validate Token', () => { // When an user with an expired token jest.spyOn(jwtConfigHandler, 'getJwtConfigByType').mockReturnValueOnce({ secret: configService.getOrThrow('ACCESS_TOKEN_SECRET'), - expiresIn: '1ms', + expiresIn: '1s', }); const { accessToken } = await jwtManager.signAccessToken('fake_id'); + // Sleep + await new Promise((resolve) => setTimeout(resolve, 1000)); // When the users tries to validate the token with type "access" const response = await testManager diff --git a/api/test/utils/user.auth.ts b/api/test/utils/user.auth.ts index 0842925a..3643bc7d 100644 --- a/api/test/utils/user.auth.ts +++ b/api/test/utils/user.auth.ts @@ -2,7 +2,14 @@ import * as request from 'supertest'; import { TestManager } from './test-manager'; import { User } from '@shared/entities/users/user.entity'; -export type TestUser = { jwtToken: string; user: User; password: string }; +export type TestUser = { + jwtToken: string; + expiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + user: User; + password: string; +}; export async function logUserIn( testManager: TestManager, @@ -13,7 +20,10 @@ export async function logUserIn( .send({ email: user.email, password: user.password }); return { - jwtToken: response.body.accessToken, + jwtToken: response.body.accessToken, // We cannot change this at the moment as a lot of tests would be affected by this + expiresAt: response.body.expiresAt, + refreshToken: response.body.refreshToken, + refreshTokenExpiresAt: response.body.refreshTokenExpiresAt, user: user as User, password: user.password, }; diff --git a/shared/config/.env.test b/shared/config/.env.test index 5e2e689b..2e45c792 100644 --- a/shared/config/.env.test +++ b/shared/config/.env.test @@ -10,6 +10,10 @@ API_URL=http://localhost:4000 ACCESS_TOKEN_SECRET=access_token_secret ACCESS_TOKEN_EXPIRES_IN=2h +# Refresh Token Configuration +REFRESH_TOKEN_SECRET=refresh_token_secret +REFRESH_TOKEN_EXPIRES_IN=30d + # Sign Up Token Configuration ACCOUNT_CONFIRMATION_TOKEN_SECRET=your_ACCOUNT_CONFIRMATION_TOKEN_SECRET ACCOUNT_CONFIRMATION_EXPIRES_IN=24h diff --git a/shared/contracts/auth.contract.ts b/shared/contracts/auth.contract.ts index d27098b7..1fca7d04 100644 --- a/shared/contracts/auth.contract.ts +++ b/shared/contracts/auth.contract.ts @@ -1,6 +1,6 @@ import { initContract } from "@ts-rest/core"; import { LogInSchema } from "@shared/schemas/auth/login.schema"; -import { UserDto, UserWithAccessToken } from "@shared/dtos/users/user.dto"; +import { UserDto, UserWithAuthTokens } from "@shared/dtos/users/user.dto"; import { TokenTypeSchema } from "@shared/schemas/auth/token-type.schema"; import { z } from "zod"; import { BearerTokenSchema } from "@shared/schemas/auth/bearer-token.schema"; @@ -8,16 +8,26 @@ import { SignUpSchema } from "@shared/schemas/auth/sign-up.schema"; import { RequestEmailUpdateSchema } from "@shared/schemas/users/request-email-update.schema"; import { ApiResponse } from "@shared/dtos/global/api-response.dto"; import { CreateUserSchema } from "@shared/schemas/users/create-user.schema"; +import { AuthTokenPair } from "@shared/dtos/auth-token-pair.dto"; +import { RefreshTokenSchema } from "@shared/schemas/auth/refresh-token.schema"; // TODO: This is a scaffold. We need to define types for responses, zod schemas for body and query param validation etc. const contract = initContract(); export const authContract = contract.router({ + refreshToken: { + method: "POST", + path: "/authentication/refresh-token", + responses: { + 200: contract.type(), + }, + body: RefreshTokenSchema, + }, login: { method: "POST", path: "/authentication/login", responses: { - 201: contract.type(), + 201: contract.type(), }, body: LogInSchema, }, diff --git a/shared/dtos/auth-token-pair.dto.ts b/shared/dtos/auth-token-pair.dto.ts new file mode 100644 index 00000000..3e6548c4 --- /dev/null +++ b/shared/dtos/auth-token-pair.dto.ts @@ -0,0 +1,6 @@ +export type AuthTokenPair = { + accessToken: string; + expiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; +}; diff --git a/shared/dtos/users/user.dto.ts b/shared/dtos/users/user.dto.ts index 61cee197..d359e00c 100644 --- a/shared/dtos/users/user.dto.ts +++ b/shared/dtos/users/user.dto.ts @@ -2,10 +2,13 @@ import { OmitType } from "@nestjs/mapped-types"; import { BackOfficeSession } from "@shared/entities/users/backoffice-session"; import { User } from "@shared/entities/users/user.entity"; -export type UserWithAccessToken = { +export type UserWithAuthTokens = { user: UserDto; accessToken: string; - backofficeSession?: BackOfficeSession + expiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + backofficeSession?: BackOfficeSession; }; export class UserDto extends OmitType(User, ["password"]) {} diff --git a/shared/schemas/auth/refresh-token.schema.ts b/shared/schemas/auth/refresh-token.schema.ts new file mode 100644 index 00000000..8dcf98ff --- /dev/null +++ b/shared/schemas/auth/refresh-token.schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const RefreshTokenSchema = z.object({ + refreshToken: z.string(), +}); diff --git a/shared/schemas/auth/token-type.schema.ts b/shared/schemas/auth/token-type.schema.ts index 4a64516e..fe72ea24 100644 --- a/shared/schemas/auth/token-type.schema.ts +++ b/shared/schemas/auth/token-type.schema.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export enum TOKEN_TYPE_ENUM { ACCESS = "access", + REFRESH = "refresh", RESET_PASSWORD = "reset-password", ACCOUNT_CONFIRMATION = "sign-up", EMAIL_CONFIRMATION = "email-confirmation",