From 0cf4f2ae193ce532c0dafb07188c7fe86e501f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20W=C4=99grzyn?= Date: Thu, 16 Oct 2025 20:12:38 +0200 Subject: [PATCH 1/2] Refresh token --- src/auth/auth.controller.ts | 7 +++++ src/auth/auth.module.ts | 15 ++++------ src/auth/jwt.service.ts | 34 ++++++++++++++++++++++ src/auth/login/login-by-habitat.service.ts | 6 ++-- src/auth/strategy/jwt.strategy.ts | 12 ++++++-- 5 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 src/auth/jwt.service.ts diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 7fab612..4a2f04a 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -103,4 +103,11 @@ export class AuthController { public getProfile(@Request() req): AuthModelInterface { return req.user; } + + @UseGuards(JwtAuthGuard) + @Get('refreshToken') + @ApiBearerAuth() + public refreshToken(@Request() req): Promise { + return this.loginService.login(req.user); + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 5a75522..015bf6f 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,16 +1,15 @@ import {Module} from '@nestjs/common'; import {ConfigModule, ConfigService} from '@nestjs/config'; import {APP_GUARD} from '@nestjs/core'; -import {JwtModule, JwtService} from '@nestjs/jwt'; +import {JwtModule} from '@nestjs/jwt'; import {JwtModuleOptions} from '@nestjs/jwt/dist/interfaces/jwt-module-options.interface'; import {PassportModule} from '@nestjs/passport'; import {Request} from 'express'; import {CLS_REQ, ClsModule} from 'nestjs-cls'; -import {ExtractJwt} from 'passport-jwt'; import {AuthController} from '@warp-core/auth/auth.controller'; import {GqlAuthGuard} from '@warp-core/auth/guard/gql-auth.guard'; -import {PayloadInterface} from '@warp-core/auth/interface/payload.interface'; +import {JwtService} from '@warp-core/auth/jwt.service'; import {LoginByHabitatService} from '@warp-core/auth/login/login-by-habitat.service'; import {AuthorizedHabitatModel} from '@warp-core/auth/payload/model/habitat.model'; import {RegisterService} from '@warp-core/auth/register/register.service'; @@ -49,6 +48,7 @@ const jwtFactory = { }, LocalStrategy, JwtStrategy, + JwtService, { provide: APP_GUARD, useClass: GqlAuthGuard, @@ -63,17 +63,14 @@ const jwtFactory = { ClsModule.forFeatureAsync({ global: true, provide: AuthorizedHabitatModel, - imports: [DatabaseModule, JwtModule], + imports: [DatabaseModule, AuthModule], inject: [CLS_REQ, HabitatRepository, JwtService], useFactory: async ( req: Request, habitatRepository: HabitatRepository, jwtService: JwtService, ) => { - const extractJwt = ExtractJwt.fromAuthHeaderAsBearerToken(); - const payload = jwtService.decode( - extractJwt(req) ?? '', - ) as PayloadInterface; + const payload = jwtService.decode(req); if (!payload) { return null; @@ -87,6 +84,6 @@ const jwtFactory = { }), ], controllers: [AuthController], - exports: [ClsModule], + exports: [ClsModule, JwtService], }) export class AuthModule {} diff --git a/src/auth/jwt.service.ts b/src/auth/jwt.service.ts new file mode 100644 index 0000000..a09fd80 --- /dev/null +++ b/src/auth/jwt.service.ts @@ -0,0 +1,34 @@ +import {Injectable} from '@nestjs/common'; +import {ConfigService} from '@nestjs/config'; +import {JwtService as NestJwtService} from '@nestjs/jwt'; +import {Request} from 'express'; +import {ExtractJwt} from 'passport-jwt'; + +import {PayloadInterface} from '@warp-core/auth/interface/payload.interface'; +import {AccessToken} from '@warp-core/auth/login/access-token.model'; + +@Injectable() +export class JwtService { + constructor( + private readonly nestJwtService: NestJwtService, + private readonly configService: ConfigService, + ) {} + + public verifyAsync(token: string): Promise { + return this.nestJwtService.verifyAsync(token, { + secret: this.configService.get('jwt.secret'), + }); + } + + public sign(payload: PayloadInterface): AccessToken { + return { + access_token: this.nestJwtService.sign(payload as PayloadInterface), + }; + } + + public decode(request: Request): PayloadInterface | undefined { + const extractJwt = ExtractJwt.fromAuthHeaderAsBearerToken(); + + return this.nestJwtService.decode(extractJwt(request) ?? ''); + } +} diff --git a/src/auth/login/login-by-habitat.service.ts b/src/auth/login/login-by-habitat.service.ts index 6ea7a38..a6a1fe1 100644 --- a/src/auth/login/login-by-habitat.service.ts +++ b/src/auth/login/login-by-habitat.service.ts @@ -1,10 +1,10 @@ import {Injectable} from '@nestjs/common'; -import {JwtService} from '@nestjs/jwt'; import {AccessToken} from './access-token.model'; import {LoginInterface} from './login.interface'; import {PayloadInterface} from '@warp-core/auth/interface/payload.interface'; +import {JwtService} from '@warp-core/auth/jwt.service'; import {AuthorizedHabitatModel} from '@warp-core/auth/payload/model/habitat.model'; @Injectable() @@ -18,8 +18,6 @@ export class LoginByHabitatService implements LoginInterface { currentHabitatId: habitat.id, } as PayloadInterface; - return { - access_token: `${this.jwtService.sign(payload)}`, - } as AccessToken; + return this.jwtService.sign(payload); } } diff --git a/src/auth/strategy/jwt.strategy.ts b/src/auth/strategy/jwt.strategy.ts index 8c6be92..e41e73a 100644 --- a/src/auth/strategy/jwt.strategy.ts +++ b/src/auth/strategy/jwt.strategy.ts @@ -16,7 +16,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - public validate(payload: PayloadInterface): AuthModelInterface { - return payload.dbModel; + public validate( + payload: PayloadInterface & { + iat: number; + exp: number; + }, + ): AuthModelInterface & { + iat: number; + exp: number; + } { + return {...payload.dbModel, iat: payload.iat, exp: payload.exp}; } } From bf2031fd23116963463966652f5fb4817424a38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20W=C4=99grzyn?= Date: Thu, 16 Oct 2025 21:01:59 +0200 Subject: [PATCH 2/2] Create refresh token tests --- config/test.yaml | 4 + src/auth/jwt.service.ts | 2 +- test/e2e/auth/token.spec.ts | 86 +++++++++++++++++++ .../graphql-request-test.ts | 6 +- 4 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 test/e2e/auth/token.spec.ts diff --git a/config/test.yaml b/config/test.yaml index 298bb20..67ca590 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -9,6 +9,10 @@ database: migrations: false cli: "migrationsDir": "test/e2e/migrations" +jwt: + secret: 'secret_code' + expiresIn: 3600 + ignoreExpiration: false security: headerTokenToLogin: null whitelistedIp: null diff --git a/src/auth/jwt.service.ts b/src/auth/jwt.service.ts index a09fd80..226ff07 100644 --- a/src/auth/jwt.service.ts +++ b/src/auth/jwt.service.ts @@ -22,7 +22,7 @@ export class JwtService { public sign(payload: PayloadInterface): AccessToken { return { - access_token: this.nestJwtService.sign(payload as PayloadInterface), + access_token: this.nestJwtService.sign(payload), }; } diff --git a/test/e2e/auth/token.spec.ts b/test/e2e/auth/token.spec.ts new file mode 100644 index 0000000..b6ccfde --- /dev/null +++ b/test/e2e/auth/token.spec.ts @@ -0,0 +1,86 @@ +import {HttpStatus, INestApplication} from '@nestjs/common'; +import * as request from 'supertest'; + +import {LoginParameters} from '@warp-core/auth/login/login-parameters.model'; +import {requestGraphQL} from '@warp-core/test/e2e/utils/graphql-request-test'; +import {GraphqlRequestTest} from '@warp-core/test/e2e/utils/graphql-request-test/graphql-request-test'; +import {createNestApplicationE2E} from '@warp-core/test/e2e/utils/setup-tests'; + +const startTimestamp = 1760083810; +const tokenRegex = /(^[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$)/; + +describe('Token', () => { + let requestTest: GraphqlRequestTest; + let app: INestApplication; + const userId = 10; + let loginParameters: LoginParameters; + let loginToken: string; + const tokenExpirationInSeconds = 3600; + + beforeEach(async () => { + app = await createNestApplicationE2E(); + requestTest = requestGraphQL(app.getHttpServer()); + jest.useFakeTimers().setSystemTime(new Date(startTimestamp * 1000)); + loginParameters = await requestTest.register(userId); + loginToken = await requestTest.authenticate(loginParameters); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should login token be created', () => { + expect(loginToken).toMatch(tokenRegex); + }); + + it('should return profile info', async () => { + const profileResponse = await request(app.getHttpServer()) + .get(`/auth/profile`) + .set('Authorization', requestTest.prepareAuthHeader()) + .expect(HttpStatus.OK); + + const profileData = profileResponse.body; + + expect(profileData.id).toEqual(loginParameters.habitatId); + expect(profileData.userId).toEqual(loginParameters.userId); + expect(profileData.iat).toEqual(startTimestamp); + expect(profileData.exp).toEqual(startTimestamp + tokenExpirationInSeconds); + }); + + it('should refresh token with new iat and exp date', async () => { + const prolongedTimestamp = startTimestamp + 3000; + jest.useFakeTimers().setSystemTime(new Date(prolongedTimestamp * 1000)); + + const refreshedTokenResponse = await request(app.getHttpServer()) + .get(`/auth/refreshToken`) + .set('Authorization', requestTest.prepareAuthHeader()) + .expect(HttpStatus.OK); + const refreshedToken = refreshedTokenResponse.body.access_token; + + expect(refreshedToken).toMatch(tokenRegex); + + const profileResponse = await request(app.getHttpServer()) + .get(`/auth/profile`) + .set('Authorization', `Bearer ${refreshedToken}`) + .expect(HttpStatus.OK); + + const profileData = profileResponse.body; + + expect(profileData.id).toEqual(loginParameters.habitatId); + expect(profileData.userId).toEqual(loginParameters.userId); + expect(profileData.iat).toEqual(prolongedTimestamp); + expect(profileData.exp).toEqual( + prolongedTimestamp + tokenExpirationInSeconds, + ); + }); + + it('should not refresh token', () => { + const prolongedTimestamp = startTimestamp + 9999; + jest.useFakeTimers().setSystemTime(new Date(prolongedTimestamp * 1000)); + + return request(app.getHttpServer()) + .get(`/auth/refreshToken`) + .set('Authorization', requestTest.prepareAuthHeader()) + .expect(HttpStatus.UNAUTHORIZED); + }); +}); diff --git a/test/e2e/utils/graphql-request-test/graphql-request-test.ts b/test/e2e/utils/graphql-request-test/graphql-request-test.ts index a091394..0b8a832 100644 --- a/test/e2e/utils/graphql-request-test/graphql-request-test.ts +++ b/test/e2e/utils/graphql-request-test/graphql-request-test.ts @@ -23,7 +23,7 @@ export class GraphqlRequestTest { return registerResponse.body; } - public async authenticate(loginParameters: LoginParameters): Promise { + public async authenticate(loginParameters: LoginParameters): Promise { const loginResponse = await this.supertest .post('/auth/login') .send(loginParameters) @@ -31,7 +31,7 @@ export class GraphqlRequestTest { this.loginToken = loginResponse.body.access_token; - return this; + return this.loginToken; } public async registerAndAuthenticate(userId: number): Promise { @@ -78,7 +78,7 @@ export class GraphqlRequestTest { return generateGraphQLQuery(this.operationName, this.queryParameters); } - private prepareAuthHeader(): string { + public prepareAuthHeader(): string { if (this.loginToken) { return `Bearer ${this.loginToken}`; }