Skip to content

Commit

Permalink
Merge pull request #342 from lenneTech/feat/same-token-id-period
Browse files Browse the repository at this point in the history
Configuation handling fixed and same token ID period implemented
  • Loading branch information
kaihaase authored Dec 13, 2023
2 parents 4a0e6d2 + a05b9cc commit 7a1195d
Show file tree
Hide file tree
Showing 11 changed files with 139 additions and 11 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lenne.tech/nest-server",
"version": "10.2.3",
"version": "10.2.4",
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
"keywords": [
"node",
Expand Down
2 changes: 1 addition & 1 deletion spectaql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ servers:
info:
title: lT Nest Server
description: Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).
version: 10.2.3
version: 10.2.4
contact:
name: lenne.Tech GmbH
url: https://lenne.tech
Expand Down
3 changes: 3 additions & 0 deletions src/config.env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const config: { [env: string]: IServerOptions } = {
expiresIn: '7d',
},
},
sameTokenIdPeriod: 2000,
},
loadLocalConfig: true,
logExceptions: true,
Expand Down Expand Up @@ -176,6 +177,7 @@ const config: { [env: string]: IServerOptions } = {
expiresIn: '7d',
},
},
sameTokenIdPeriod: 2000,
},
loadLocalConfig: false,
logExceptions: true,
Expand Down Expand Up @@ -266,6 +268,7 @@ const config: { [env: string]: IServerOptions } = {
expiresIn: '7d',
},
},
sameTokenIdPeriod: 2000,
},
loadLocalConfig: false,
logExceptions: true,
Expand Down
7 changes: 7 additions & 0 deletions src/core/common/interfaces/server-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,13 @@ export interface IServerOptions {
*/
renewal?: boolean;
} & IJwt;

/**
* Time period in milliseconds
* in which the same token ID is used so that all parallel token refresh requests of a device can be generated.
* default: 0 (every token includes a new token ID, all parallel token refresh requests must be prevented by the client or processed accordingly)
*/
sameTokenIdPeriod?: number;
} & IJwt &
JwtModuleOptions;

Expand Down
4 changes: 2 additions & 2 deletions src/core/common/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class ConfigService {
// Init subject handling
if (!isInitialized) {
ConfigService._configSubject$.subscribe((config) => {
ConfigService._frozenConfigSubject$.next(deepFreeze(config));
ConfigService._frozenConfigSubject$.next(deepFreeze(cloneDeep(config)));
});
}

Expand Down Expand Up @@ -370,7 +370,7 @@ export class ConfigService {
* Set config property in ConfigService
*/
setProperty(key: string, value: any, options?: { warn?: boolean }) {
return ConfigService.setProperty(key, options);
return ConfigService.setProperty(key, value, options);
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/core/modules/auth/interfaces/core-auth-user.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ export interface ICoreAuthUser {
* Refresh tokens for different devices
*/
refreshTokens?: Record<string, CoreTokenData>;

/**
* Temporary tokens for parallel requests during the token refresh process
* See sameTokenIdPeriod in configuration
*/
tempTokens?: Record<string, { createdAt: number; deviceId: string; tokenId: string }>;
}
30 changes: 26 additions & 4 deletions src/core/modules/auth/services/core-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,26 @@ export class CoreAuthService {
* Get JWT and refresh token
*/
protected async createTokens(userId: string, data?: { [key: string]: any; deviceId?: string }) {
const payload: { [key: string]: any; id: string; deviceId: string } = {

// Initializations
const sameTokenIdPeriod: number = this.configService.getFastButReadOnly('jwt.sameTokenIdPeriod', 0);
const deviceId = data?.deviceId || randomUUID();

// Use last token ID or a new one
let tokenId: string = randomUUID();
if (sameTokenIdPeriod) {
const user: ICoreAuthUser = await this.userService.get(userId, { force: true });
const tempToken = user?.tempTokens?.[deviceId];
if (tempToken && tempToken.tokenId && tempToken.createdAt >= new Date().getTime() - sameTokenIdPeriod) {
tokenId = tempToken.tokenId;
}
}

const payload: { [key: string]: any; id: string; deviceId: string; tokenId: string } = {
...data,
id: userId,
deviceId: data?.deviceId || randomUUID(),
tokenId: randomUUID(),
deviceId,
tokenId,
};
const [token, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
Expand Down Expand Up @@ -253,6 +268,9 @@ export class CoreAuthService {
if (!user.refreshTokens) {
user.refreshTokens = {};
}
if (!user.tempTokens) {
user.tempTokens = {};
}
if (deviceId) {
const oldData = user.refreshTokens[deviceId] || {};
data = Object.assign(oldData, data);
Expand All @@ -267,7 +285,11 @@ export class CoreAuthService {
deviceId = payload.deviceId;
}
user.refreshTokens[deviceId] = { ...data, deviceId, tokenId: payload.tokenId };
await this.userService.update(getStringIds(user), { refreshTokens: user.refreshTokens }, { force: true });
user.tempTokens[deviceId] = { createdAt: new Date().getTime(), deviceId, tokenId: payload.tokenId };
await this.userService.update(getStringIds(user), {
refreshTokens: user.refreshTokens,
tempTokens: user.tempTokens,
}, { force: true });

// Return new token
return newRefreshToken;
Expand Down
8 changes: 8 additions & 0 deletions src/core/modules/user/core-user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ export abstract class CoreUserModel extends CorePersistenceModel {
@Prop(raw({}))
refreshTokens: Record<string, CoreTokenData> = undefined;

/**
* Temporary token for parallel requests during the token refresh process
* See sameTokenIdPeriod in configuration
*/
@IsOptional()
@Prop(raw({}))
tempTokens: Record<string, { createdAt: number; deviceId: string; tokenId: string }> = undefined;

/**
* Verification token of the user
*/
Expand Down
8 changes: 8 additions & 0 deletions src/test/test.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,4 +606,12 @@ export class TestHelper {
// Return subscribed messages
return messages;
}

/**
* Convert JWT into to object
* @param token
*/
parseJwt(token) {
return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
}
}
76 changes: 75 additions & 1 deletion tests/server.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PubSub } from 'graphql-subscriptions';
import { MongoClient, ObjectId } from 'mongodb';
import { ComparisonOperatorEnum, HttpExceptionLogFilter, TestGraphQLType, TestHelper } from '../src';
import { ComparisonOperatorEnum, ConfigService, HttpExceptionLogFilter, TestGraphQLType, TestHelper } from '../src';
import envConfig from '../src/config.env';
import { getPlain } from '../src/core/common/helpers/input.helper';
import { UserCreateInput } from '../src/server/modules/user/inputs/user-create.input';
Expand All @@ -25,13 +25,18 @@ describe('ServerModule (e2e)', () => {

// Services
let userService: UserService;
let configService: ConfigService;

// Original data
let oTempTokenPeriod: number;

// Global vars
let gId: string;
let gEmail: string;
let gPassword: string;
let gToken: string;
let gRefreshToken: string;
let gLastRefreshRequestTime: number;

// ===================================================================================================================
// Preparations
Expand Down Expand Up @@ -64,6 +69,8 @@ describe('ServerModule (e2e)', () => {
await app.init();
testHelper = new TestHelper(app, `ws://127.0.0.1:${port}/graphql`);
userService = moduleFixture.get(UserService);
configService = moduleFixture.get(ConfigService);
oTempTokenPeriod = envConfig.jwt.sameTokenIdPeriod;
await app.listen(port, '127.0.0.1'); // app.listen is required by subscriptions

// Connection to database
Expand Down Expand Up @@ -258,6 +265,29 @@ describe('ServerModule (e2e)', () => {
* Get refresh token with refresh token
*/
it('getRefreshTokenWithRefreshToken', async () => {
gLastRefreshRequestTime = Date.now();
const res: any = await testHelper.graphQl(
{
name: 'refreshToken',
type: TestGraphQLType.MUTATION,
fields: ['token', 'refreshToken', { user: ['id', 'email'] }],
},
{ token: gRefreshToken },
);
expect(res.user.id).toEqual(gId);
expect(res.user.email).toEqual(gEmail);
expect(res.token.length).toBeGreaterThan(0);
expect(res.refreshToken.length).toBeGreaterThan(0);
expect(res.token.length).not.toEqual(gToken);
expect(res.refreshToken.length).not.toEqual(gRefreshToken);
gToken = res.token;
gRefreshToken = res.refreshToken;
});

/**
* Get refresh token with refresh token again to check the temporary tokenId
*/
it('getRefreshTokenWithRefreshTokenAgain', async () => {
const res: any = await testHelper.graphQl(
{
name: 'refreshToken',
Expand All @@ -272,6 +302,50 @@ describe('ServerModule (e2e)', () => {
expect(res.refreshToken.length).toBeGreaterThan(0);
expect(res.token.length).not.toEqual(gToken);
expect(res.refreshToken.length).not.toEqual(gRefreshToken);
if (envConfig.jwt.sameTokenIdPeriod) {
const timeBetween = Date.now() - gLastRefreshRequestTime;
console.debug(`tempToken used | config: ${envConfig.jwt.sameTokenIdPeriod}, timeBetween: ${timeBetween}, rest: ${envConfig.jwt.sameTokenIdPeriod - timeBetween}`);
expect(gLastRefreshRequestTime).toBeGreaterThanOrEqual(Date.now() - envConfig.jwt.sameTokenIdPeriod);
expect(testHelper.parseJwt(res.token).tokenId).toEqual(testHelper.parseJwt(gToken).tokenId);
} else {
console.debug('tempToken not used');
expect(testHelper.parseJwt(res.token).tokenId).not.toEqual(testHelper.parseJwt(gToken).tokenId);
}
gToken = res.token;
gRefreshToken = res.refreshToken;
});

/**
* Get refresh token with refresh token again to check the temporary tokenId with other config
*/
it('getRefreshTokenWithRefreshTokenOtherConfig', async () => {
const sameTokenIdPeriod = oTempTokenPeriod ? 0 : 200;
configService.setProperty('jwt.sameTokenIdPeriod', sameTokenIdPeriod);
expect(configService.getFastButReadOnly('jwt.sameTokenIdPeriod')).toEqual(sameTokenIdPeriod);
expect(configService.getFastButReadOnly('jwt.sameTokenIdPeriod')).not.toEqual(oTempTokenPeriod);
const res: any = await testHelper.graphQl(
{
name: 'refreshToken',
type: TestGraphQLType.MUTATION,
fields: ['token', 'refreshToken', { user: ['id', 'email'] }],
},
{ token: gRefreshToken },
);
expect(res.user.id).toEqual(gId);
expect(res.user.email).toEqual(gEmail);
expect(res.token.length).toBeGreaterThan(0);
expect(res.refreshToken.length).toBeGreaterThan(0);
expect(res.token.length).not.toEqual(gToken);
expect(res.refreshToken.length).not.toEqual(gRefreshToken);
if (sameTokenIdPeriod) {
const timeBetween = Date.now() - gLastRefreshRequestTime;
console.debug(`tempToken2 used | config: ${sameTokenIdPeriod}, timeBetween: ${timeBetween}, rest: ${sameTokenIdPeriod - timeBetween}`);
expect(testHelper.parseJwt(res.token).tokenId).toEqual(testHelper.parseJwt(gToken).tokenId);
} else {
console.debug('tempToken2 not used');
expect(testHelper.parseJwt(res.token).tokenId).not.toEqual(testHelper.parseJwt(gToken).tokenId);
}
configService.setProperty('jwt.sameTokenIdPeriod', oTempTokenPeriod);
gToken = res.token;
gRefreshToken = res.refreshToken;
});
Expand Down

0 comments on commit 7a1195d

Please sign in to comment.