From 0ced80374c4708af763dc537433600c766ffc52f Mon Sep 17 00:00:00 2001 From: alexeh Date: Sat, 21 Sep 2024 07:10:35 +0200 Subject: [PATCH] validate token tests --- .../authentication/authentication.service.ts | 4 +- api/test/e2e/features/validate-token.feature | 62 +++ api/test/e2e/steps/password-recovery.steps.ts | 2 +- api/test/e2e/steps/validate-token.steps.ts | 400 ++++++++++++++++++ shared/contracts/auth/auth.contract.ts | 3 +- shared/schemas/auth/bearer-token.schema.ts | 11 + 6 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 api/test/e2e/features/validate-token.feature create mode 100644 api/test/e2e/steps/validate-token.steps.ts create mode 100644 shared/schemas/auth/bearer-token.schema.ts diff --git a/api/src/modules/auth/authentication/authentication.service.ts b/api/src/modules/auth/authentication/authentication.service.ts index 9f3379df..52c8087d 100644 --- a/api/src/modules/auth/authentication/authentication.service.ts +++ b/api/src/modules/auth/authentication/authentication.service.ts @@ -46,10 +46,10 @@ export class AuthenticationService { async verifyToken(token: string, type: TOKEN_TYPE_ENUM): Promise { const { secret } = this.apiConfig.getJWTConfigByType(type); try { - this.jwt.verify(token, { secret }); + await this.jwt.verify(token, { secret }); return true; } catch (error) { - return false; + throw new UnauthorizedException(); } } } diff --git a/api/test/e2e/features/validate-token.feature b/api/test/e2e/features/validate-token.feature new file mode 100644 index 00000000..81298249 --- /dev/null +++ b/api/test/e2e/features/validate-token.feature @@ -0,0 +1,62 @@ +Feature: Validate Token + + # Scenarios for Reset Password Tokens + + Scenario: Validating a valid reset-password token + Given a user has requested a password reset + When the user attempts to validate the token with type "reset-password" + Then the user should receive a 200 status code + + Scenario: Validating an expired reset-password token + Given a reset-password token has expired + When the user attempts to validate the expired token with type "reset-password" + Then the user should receive a 401 status code + + Scenario: Validating a reset-password token with an invalid signature + Given a reset-password token has an invalid signature + When the user attempts to validate the token with type "reset-password" + Then the user should receive a 401 status code + + + Scenario: Validating a reset-password token with an incorrect type parameter + Given a user has a valid reset-password token + When the user attempts to validate the token with type "access" + Then the user should receive a 401 status code + + Scenario: Validating a reset-password token without specifying the type + Given a user has a valid reset-password token + When the user attempts to validate the token without specifying the type + Then the user should receive a 400 status code + ## TODO: Include this step when implemented common error shapes + # And the response message should include "expected": "'access' | 'reset-password' | 'email-confirmation'" + + # Scenarios for Access Tokens + + Scenario: Validating a valid access token + Given a user has a valid access token + When the user attempts to validate the token with type "access" + Then the user should receive a 200 status code + + Scenario: Validating an expired access token + Given an access token has expired + When the user attempts to validate the expired token with type "access" + Then the user should receive a 401 status code + + Scenario: Validating an access token with an invalid signature + Given an access token has an invalid signature + When the user attempts to validate the token with type "access" + Then the user should receive a 401 status code + + Scenario: Validating an access token with an incorrect type parameter + Given a user has a valid access token + When the user attempts to validate the token with type "reset-password" + Then the user should receive a 401 status code + + # Common Scenarios for Both Token Types + + Scenario: Validating a token without providing the Authorization header + When the user attempts to validate a token without providing the Authorization header + Then the user should receive a 400 status code + + + diff --git a/api/test/e2e/steps/password-recovery.steps.ts b/api/test/e2e/steps/password-recovery.steps.ts index 490ef24e..fc0c43d6 100644 --- a/api/test/e2e/steps/password-recovery.steps.ts +++ b/api/test/e2e/steps/password-recovery.steps.ts @@ -9,7 +9,7 @@ const feature = loadFeature( './test/e2e/features/password-recovery-send-email.feature', ); -describe('test', () => { +describe('Password Recovery - Send Email', () => { defineFeature(feature, (test) => { let testManager: TestManager; let testUser: User; diff --git a/api/test/e2e/steps/validate-token.steps.ts b/api/test/e2e/steps/validate-token.steps.ts new file mode 100644 index 00000000..96a11b85 --- /dev/null +++ b/api/test/e2e/steps/validate-token.steps.ts @@ -0,0 +1,400 @@ +import { defineFeature, loadFeature } from 'jest-cucumber'; +import { Response } from 'supertest'; + +import { User } from '@shared/entities/users/user.entity'; +import { TestManager } from '../../utils/test-manager'; +import { ApiConfigService } from '@api/modules/config/app-config.service'; +import { JwtService } from '@nestjs/jwt'; +import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; + +const feature = loadFeature('./test/e2e/features/validate-token.feature'); + +describe('Validate Token', () => { + defineFeature(feature, (test) => { + let testManager: TestManager; + let apiConfig: ApiConfigService; + let jwtService: JwtService; + let accessTokenSecret: string; + let resetPasswordTokenSecret: string; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + apiConfig = + testManager.moduleFixture.get(ApiConfigService); + jwtService = testManager.moduleFixture.get(JwtService); + }); + + beforeEach(async () => { + await testManager.clearDatabase(); + accessTokenSecret = apiConfig.getJWTConfigByType( + TOKEN_TYPE_ENUM.ACCESS, + ).secret; + resetPasswordTokenSecret = apiConfig.getJWTConfigByType( + TOKEN_TYPE_ENUM.RESET_PASSWORD, + ).secret; + }); + + afterAll(async () => { + await testManager.close(); + }); + + // Scenarios for Reset Password Tokens + + test('Validating a valid reset-password token', ({ given, when, then }) => { + let user: User; + let resetPasswordToken: string; + let response: Response; + + given('a user has requested a password reset', async () => { + user = await testManager.mocks().createUser({ + email: 'resetuser@example.com', + password: 'password123', + }); + resetPasswordToken = jwtService.sign( + { id: user.id }, + { + secret: resetPasswordTokenSecret, + expiresIn: '1h', + }, + ); + }); + + when( + 'the user attempts to validate the token with type "reset-password"', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .set('Authorization', `Bearer ${resetPasswordToken}`) + .query({ tokenType: TOKEN_TYPE_ENUM.RESET_PASSWORD }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + async (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + + test('Validating an expired reset-password token', ({ + given, + when, + then, + }) => { + let resetPasswordToken: string; + let response: Response; + + given('a reset-password token has expired', async () => { + // Create an expired token by setting expiration time in the past + resetPasswordToken = jwtService.sign( + { id: 'fake-user-id' }, + { secret: resetPasswordTokenSecret, expiresIn: '1ms' }, + ); + }); + + when( + 'the user attempts to validate the expired token with type "reset-password"', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .set('Authorization', `Bearer ${resetPasswordToken}`) + .query({ tokenType: TOKEN_TYPE_ENUM.RESET_PASSWORD }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + async (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + + test('Validating a reset-password token with an invalid signature', ({ + given, + when, + then, + }) => { + let resetPasswordToken: string; + let response: Response; + + given('a reset-password token has an invalid signature', async () => { + resetPasswordToken = jwtService.sign( + { id: 'fake-user-id' }, + { secret: 'invalid', expiresIn: '2h' }, + ); + }); + + when( + 'the user attempts to validate the token with type "reset-password"', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .set('Authorization', `Bearer ${resetPasswordToken}`) + .query({ tokenType: TOKEN_TYPE_ENUM.RESET_PASSWORD }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + async (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + + test('Validating a reset-password token with an incorrect type parameter', ({ + given, + when, + then, + }) => { + let resetPasswordToken: string; + let response: Response; + + given('a user has a valid reset-password token', async () => { + const user = await testManager.mocks().createUser({ + email: 'incorrecttype@example.com', + password: 'password123', + }); + resetPasswordToken = jwtService.sign( + { id: user.id }, + { + secret: resetPasswordTokenSecret, + expiresIn: '2h', + }, + ); + }); + + when( + 'the user attempts to validate the token with type "access"', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .set('Authorization', `Bearer ${resetPasswordToken}`) + .query({ tokenType: TOKEN_TYPE_ENUM.ACCESS }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + async (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + + test('Validating a reset-password token without specifying the type', ({ + given, + when, + then, + }) => { + let resetPasswordToken: string; + let response: Response; + + given('a user has a valid reset-password token', async () => { + const user = await testManager.mocks().createUser({ + email: 'notype@example.com', + password: 'password123', + }); + resetPasswordToken = jwtService.sign( + { id: user.id }, + { secret: resetPasswordTokenSecret, expiresIn: '2h' }, + ); + }); + + when( + 'the user attempts to validate the token without specifying the type', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .set('Authorization', `Bearer ${resetPasswordToken}`); + }, + ); + + then( + /the user should receive a (\d+) status code/, + (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + + // and( + // /^the response message should include "expected": "(.*)"$/, + // (expected: string) => { + // expect(response.body.message).toContain(`expected": "${expected}"`); + // }, + // ); + }); + + // Scenarios for Access Tokens + + test('Validating a valid access token', ({ given, when, then }) => { + let user: User; + let accessToken: string; + let response: Response; + + given('a user has a valid access token', async () => { + user = await testManager.mocks().createUser({ + email: 'accesstokenuser@example.com', + password: 'password123', + }); + accessToken = jwtService.sign( + { id: user.id }, + { secret: accessTokenSecret, expiresIn: '1h' }, + ); + }); + + when( + 'the user attempts to validate the token with type "access"', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .set('Authorization', `Bearer ${accessToken}`) + .query({ tokenType: TOKEN_TYPE_ENUM.ACCESS }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + + test('Validating an expired access token', ({ given, when, then }) => { + let expiredAccessToken: string; + let response: Response; + + given('an access token has expired', async () => { + expiredAccessToken = jwtService.sign( + { id: 'fake-user-id' }, + { secret: accessTokenSecret, expiresIn: '1ms' }, + ); + }); + + when( + 'the user attempts to validate the expired token with type "access"', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .set('Authorization', `Bearer ${expiredAccessToken}`) + .query({ tokenType: TOKEN_TYPE_ENUM.ACCESS }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + + test('Validating an access token with an invalid signature', ({ + given, + when, + then, + }) => { + let invalidSignatureAccessToken: string; + let response: Response; + + given('an access token has an invalid signature', async () => { + invalidSignatureAccessToken = jwtService.sign( + { id: 'fake-user-id' }, + { secret: 'fake-secret', expiresIn: '1h' }, + ); + }); + + when( + 'the user attempts to validate the token with type "access"', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .set('Authorization', `Bearer ${invalidSignatureAccessToken}`) + .query({ tokenType: TOKEN_TYPE_ENUM.ACCESS }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + + test('Validating an access token with an incorrect type parameter', ({ + given, + when, + then, + }) => { + let accessToken: string; + let response: Response; + + given('a user has a valid access token', async () => { + const user = await testManager.mocks().createUser({ + email: 'incorrecttypeaccess@example.com', + password: 'password123', + }); + accessToken = jwtService.sign( + { id: user.id }, + { secret: accessTokenSecret, expiresIn: '2h' }, + ); + }); + + when( + 'the user attempts to validate the token with type "reset-password"', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .set('Authorization', `Bearer ${accessToken}`) + .query({ tokenType: TOKEN_TYPE_ENUM.RESET_PASSWORD }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + + // Common Scenarios for Both Token Types + + test('Validating a token without providing the Authorization header', ({ + when, + then, + }) => { + let response: Response; + + when( + 'the user attempts to validate a token without providing the Authorization header', + async () => { + response = await testManager + .request() + .get('/authentication/validate-token') + .query({ tokenType: TOKEN_TYPE_ENUM.ACCESS }); // Type can be either 'access' or 'reset-password' + }, + ); + + then( + /the user should receive a (\d+) status code/, + (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + }); +}); diff --git a/shared/contracts/auth/auth.contract.ts b/shared/contracts/auth/auth.contract.ts index d7dc6a49..bee9d312 100644 --- a/shared/contracts/auth/auth.contract.ts +++ b/shared/contracts/auth/auth.contract.ts @@ -4,6 +4,7 @@ import { UserWithAccessToken } from "@shared/dtos/user.dto"; import { JSONAPIError } from "@shared/dtos/json-api.error"; import { TokenTypeSchema } from "@shared/schemas/auth/token-type.schema"; import { z } from "zod"; +import { BearerTokenSchema } from "@shared/schemas/auth/bearer-token.schema"; // TODO: This is a scaffold. We need to define types for responses, zod schemas for body and query param validation etc. @@ -21,7 +22,7 @@ export const authContract = contract.router({ validateToken: { method: "GET", path: "/authentication/validate-token", - headers: z.object({ authorization: z.string() }), + headers: BearerTokenSchema, responses: { 200: null, 401: null, diff --git a/shared/schemas/auth/bearer-token.schema.ts b/shared/schemas/auth/bearer-token.schema.ts new file mode 100644 index 00000000..b740a959 --- /dev/null +++ b/shared/schemas/auth/bearer-token.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const BearerTokenSchema = z.object({ + authorization: z + .string() + .regex( + /^Bearer\s+(.+)$/, + 'Authorization must be in the format "Bearer XXX"', + ) + .transform((val) => val.split(" ")[1]), +});