Skip to content

Commit

Permalink
feat(api): Implement refresh token strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
alepefe committed Jan 16, 2025
1 parent b8be986 commit be8392d
Show file tree
Hide file tree
Showing 14 changed files with 279 additions and 42 deletions.
11 changes: 11 additions & 0 deletions api/src/modules/auth/authentication.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export class AuthenticationController {
private readonly configService: ApiConfigService,
) {}

@Public()
@TsRestHandler(authContract.refreshToken)
public async refreshToken(): Promise<ControllerResponse> {
return tsRestHandler(authContract.refreshToken, async ({ body }) => {
return {
status: 200,
body: await this.authService.refreshAuthTokens(body.refreshToken),
};
});
}

@Public()
@TsRestHandler(authContract.register)
async register(
Expand Down
43 changes: 25 additions & 18 deletions api/src/modules/auth/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -42,7 +45,13 @@ export class AuthenticationService {
private readonly passwordManager: PasswordManager,
@InjectRepository(BackOfficeSession)
private readonly backOfficeSessionRepository: Repository<BackOfficeSession>,
private readonly config: ApiConfigService,
) {}

async refreshAuthTokens(refreshToken: string): Promise<AuthTokenPair> {
return this.jwtManager.refreshAuthTokens(refreshToken);
}

async validateUser(email: string, password: string): Promise<User> {
const user = await this.usersService.findByEmail(email);
if (user?.isActive && (await bcrypt.compare(password, user.password))) {
Expand Down Expand Up @@ -105,24 +114,22 @@ 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: {
cookie: {
secure: false,
httpOnly: true,
path: '/',
maxAge: expiresAt,
},
adminUser: {
id: user.id,
Expand All @@ -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<void> {
Expand Down
100 changes: 84 additions & 16 deletions api/src/modules/auth/services/jwt.manager.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,30 +14,93 @@ export class JwtManager {
private readonly users: UsersService,
) {}

private async sign(
public async refreshAuthTokens(refreshToken: string): Promise<AuthTokenPair> {
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<AuthTokenPair> {
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,
};
}
Expand Down Expand Up @@ -82,7 +147,10 @@ export class JwtManager {
async isTokenValid(token: string, type: TOKEN_TYPE_ENUM): Promise<boolean> {
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:
/**
Expand Down
8 changes: 8 additions & 0 deletions api/src/modules/config/auth-config.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export class JwtConfigHandler {
),
};

case TOKEN_TYPE_ENUM.REFRESH:
return {
secret: this.configService.getOrThrow<string>('REFRESH_TOKEN_SECRET'),
expiresIn: this.configService.getOrThrow<string>(
'REFRESH_TOKEN_EXPIRES_IN',
),
};

case TOKEN_TYPE_ENUM.RESET_PASSWORD:
return {
secret: this.configService.getOrThrow<string>(
Expand Down
19 changes: 19 additions & 0 deletions api/src/utils/time.utils.ts
Original file line number Diff line number Diff line change
@@ -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;
80 changes: 80 additions & 0 deletions api/test/integration/auth/refresh-token.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
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: '[email protected]',
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);
});
});
});
9 changes: 7 additions & 2 deletions api/test/integration/auth/validate-token.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit be8392d

Please sign in to comment.