@boarteam/boar-pack-users-backend 6.0.2 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boarteam/boar-pack-users-backend",
3
- "version": "6.0.2",
3
+ "version": "6.1.0",
4
4
  "description": "NestJS Users module including permissions system, authentication strategies etc",
5
5
  "main": "src/index",
6
6
  "files": [
@@ -14,7 +14,7 @@
14
14
  "access": "public"
15
15
  },
16
16
  "dependencies": {
17
- "@boarteam/boar-pack-common-backend": "^3.0.1",
17
+ "@boarteam/boar-pack-common-backend": "^3.1.0",
18
18
  "@casl/ability": "^6.7.3",
19
19
  "@dataui/crud": "^5.3.4",
20
20
  "@dataui/crud-typeorm": "^5.3.4",
@@ -32,6 +32,7 @@
32
32
  "lodash": "^4.17.21",
33
33
  "moment": "^2.30.1",
34
34
  "moment-timezone": "^0.5.47",
35
+ "ms": "^2.1.3",
35
36
  "nestjs-joi": "^1.10.1",
36
37
  "openapi-typescript-codegen": "^0.29.0",
37
38
  "passport": "^0.7.0",
@@ -49,6 +50,7 @@
49
50
  },
50
51
  "devDependencies": {
51
52
  "@types/bcrypt": "^5.0.2",
53
+ "@types/ms": "^2.1.0",
52
54
  "@types/passport-google-oauth20": "^2.0.16",
53
55
  "@types/passport-http-bearer": "^1.0.41",
54
56
  "@types/passport-jwt": "^4.0.1",
@@ -62,5 +64,5 @@
62
64
  "yalc:push": "yalc push",
63
65
  "gen-types": "SWAGGER=true JWT_SECRET=swagger nest start"
64
66
  },
65
- "gitHead": "e34dd089e6c90e6466fba9d27c20dd121a2301ca"
67
+ "gitHead": "18738ad91a207f2961e7571eddc3b5a5c76430f2"
66
68
  }
@@ -1,6 +1,5 @@
1
1
  import { Controller, NotFoundException, Param, Post, Req, Res, } from '@nestjs/common';
2
2
  import { AuthService } from './auth.service';
3
- import { tokenName } from './auth.constants';
4
3
  import { ApiTags } from '@nestjs/swagger';
5
4
  import type { Request, Response } from 'express';
6
5
  import { LocalAuthTokenDto } from "./local-auth/local-auth.dto";
@@ -29,8 +28,8 @@ export default class AuthManageController {
29
28
  throw new NotFoundException(`User with id ${userId} is not found`);
30
29
  }
31
30
 
32
- const loginResult = await this.authService.login(user);
33
- this.authService.setCookie(res, loginResult.accessToken);
34
- return loginResult;
31
+ const tokens = await this.authService.login(user);
32
+ this.authService.setCookie(res, tokens);
33
+ return tokens;
35
34
  }
36
35
  }
@@ -1,5 +1,6 @@
1
1
  export const LOCAL_AUTH = 'local';
2
2
  export const JWT_AUTH = 'jwt';
3
+ export const JWT_AUTH_REFRESH = 'jwt-refresh';
3
4
  export const GOOGLE_AUTH = 'google';
4
5
  export const MS_AUTH = 'azure-ad';
5
6
 
@@ -0,0 +1,20 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+
4
+ export type TAuthConfig = {
5
+ refreshTokenPath: string;
6
+ };
7
+
8
+ @Injectable()
9
+ export class AuthConfigService {
10
+ constructor(private configService: ConfigService) {
11
+ }
12
+
13
+ get config(): TAuthConfig {
14
+ const refreshTokenPath = this.configService.get<string>('REFRESH_TOKEN_PATH', '/api/auth/refresh');
15
+
16
+ return {
17
+ refreshTokenPath,
18
+ };
19
+ }
20
+ }
@@ -1 +1,2 @@
1
1
  export const tokenName = 'auth_token';
2
+ export const refreshTokenName = 'auth_refresh_token';
@@ -2,9 +2,10 @@ import { Controller, Post, Req, Res, UnauthorizedException, UseGuards } from '@n
2
2
  import { ApiTags } from '@nestjs/swagger';
3
3
  import type { Request, Response } from 'express';
4
4
  import { AuthService } from './auth.service';
5
- import { JwtAuthGuard } from '../jwt-auth/jwt-auth.guard';
5
+ import { JwtAuthGuard, SkipJWTGuard } from '../jwt-auth/jwt-auth.guard';
6
6
  import { SkipPoliciesGuard } from '../casl/policies.guard';
7
7
  import { LocalAuthTokenDto } from "./local-auth/local-auth.dto";
8
+ import { JwtAuthRefreshGuard } from "../jwt-auth/jwt-auth.refresh.guard";
8
9
 
9
10
  @SkipPoliciesGuard()
10
11
  @ApiTags('Authentication')
@@ -33,6 +34,21 @@ export default class AuthController {
33
34
  await this.authService.logout(req.jwt);
34
35
  }
35
36
 
36
- this.authService.setCookie(res, '');
37
+ this.authService.clearCookies(res);
38
+ }
39
+
40
+ @SkipJWTGuard()
41
+ @UseGuards(JwtAuthRefreshGuard)
42
+ @Post('refresh')
43
+ async refresh(
44
+ @Req() req: Request,
45
+ @Res({ passthrough: true }) res: Response,
46
+ ): Promise<void> {
47
+ if (!req.user) {
48
+ throw new UnauthorizedException(`User is not authorized`);
49
+ }
50
+
51
+ const tokens = await this.authService.login(req.user);
52
+ this.authService.setCookie(res, tokens);
37
53
  }
38
54
  }
@@ -19,6 +19,7 @@ import LocalAuthController from "./local-auth/local-auth.controller";
19
19
  import { YandexAuthStrategy } from "./yandex/yandex-auth.strategy";
20
20
  import { YandexAuthConfigService } from "./yandex/yandex-auth.config";
21
21
  import YandexAuthController from "./yandex/yandex-auth.controller";
22
+ import { AuthConfigService } from "./auth.config";
22
23
 
23
24
  @Module({})
24
25
  export class AuthModule {
@@ -45,6 +46,7 @@ export class AuthModule {
45
46
  ],
46
47
  providers: [
47
48
  AuthService,
49
+ AuthConfigService,
48
50
  {
49
51
  provide: APP_GUARD,
50
52
  useClass: JwtAuthGuard,
@@ -102,6 +104,7 @@ export class AuthModule {
102
104
  }),
103
105
  ],
104
106
  providers: [
107
+ AuthConfigService,
105
108
  AuthService,
106
109
  {
107
110
  provide: APP_GUARD,
@@ -1,10 +1,12 @@
1
1
  import { Injectable, Logger } from '@nestjs/common';
2
2
  import { TUser, UsersService } from '../users';
3
- import { JWTAuthService, TJWTPayload } from '../jwt-auth';
3
+ import { JWTAuthService, TJWTPayload, TJWTRefreshPayload } from '../jwt-auth';
4
4
  import bcrypt from 'bcrypt';
5
5
  import { LocalAuthTokenDto } from "./local-auth/local-auth.dto";
6
6
  import { Response } from 'express';
7
- import { tokenName } from "./auth.constants";
7
+ import { refreshTokenName, tokenName } from "./auth.constants";
8
+ import { AuthConfigService, TAuthConfig } from "./auth.config";
9
+ import { TOKEN_TYPE } from "../revoked-tokens";
8
10
 
9
11
  declare global {
10
12
  namespace Express {
@@ -18,11 +20,15 @@ declare global {
18
20
  @Injectable()
19
21
  export class AuthService {
20
22
  private readonly logger = new Logger(AuthService.name);
23
+ private readonly config: TAuthConfig;
21
24
 
22
25
  constructor(
23
26
  private usersService: UsersService,
24
27
  private jwtAuthService: JWTAuthService,
25
- ) {}
28
+ private readonly authConfigService: AuthConfigService,
29
+ ) {
30
+ this.config = this.authConfigService.config;
31
+ }
26
32
 
27
33
  async validateUser(email: string, pass: string): Promise<TUser | null> {
28
34
  const user = await this.usersService.findByEmail(email);
@@ -52,27 +58,65 @@ export class AuthService {
52
58
  }
53
59
 
54
60
  async login(user: Pick<TUser, 'email' | 'id'>): Promise<LocalAuthTokenDto> {
55
- const payload: TJWTPayload = { email: user.email, sub: user.id };
61
+ const sid = this.jwtAuthService.generateJwtId();
62
+ const payload: TJWTPayload = {
63
+ email: user.email,
64
+ sub: user.id,
65
+ sid,
66
+ };
67
+ const refreshPayload: TJWTRefreshPayload = {
68
+ sub: user.id,
69
+ sid,
70
+ }
56
71
  return {
57
- accessToken: this.jwtAuthService.sign(payload),
72
+ accessToken: this.jwtAuthService.sign(payload, TOKEN_TYPE.ACCESS),
73
+ refreshToken: this.jwtAuthService.sign(refreshPayload, TOKEN_TYPE.REFRESH),
58
74
  };
59
75
  }
60
76
 
61
77
  async logout(jwt: TJWTPayload): Promise<void> {
62
78
  if (!jwt.jti || !jwt.exp) {
63
- this.logger.warn('JWT does not have JTI or exp, cannot log out');
79
+ this.logger.warn('JWT does not have JTI or exp, cannot revoke it');
64
80
  return;
65
81
  }
66
82
 
67
- await this.jwtAuthService.revokeToken(jwt.jti, new Date(jwt.exp * 1000));
83
+ await this.jwtAuthService.revokeToken({
84
+ jti: jwt.jti,
85
+ expiresAt: new Date(jwt.exp * 1000),
86
+ tokenType: TOKEN_TYPE.ACCESS,
87
+ sid: jwt.sid || null,
88
+ });
68
89
  this.logger.log(`User with id ${jwt.sub} has been logged out and token revoked`);
69
90
  }
70
91
 
71
- public setCookie(res: Response, token: string): void {
72
- res.cookie(tokenName, token, {
92
+ public setCookie(res: Response, tokens: LocalAuthTokenDto): void {
93
+ res.cookie(tokenName, tokens.accessToken.token, {
94
+ httpOnly: true,
95
+ secure: process.env.SECURE_COOKIE === 'true',
96
+ sameSite: 'lax',
97
+ maxAge: tokens.accessToken.payload.exp && Math.max((tokens.accessToken.payload.exp * 1000) - Date.now(), 0),
98
+ });
99
+
100
+ res.cookie(refreshTokenName, tokens.refreshToken.token, {
101
+ httpOnly: true,
102
+ secure: process.env.SECURE_COOKIE === 'true',
103
+ sameSite: 'lax',
104
+ maxAge: tokens.refreshToken.payload.exp && Math.max((tokens.refreshToken.payload.exp * 1000) - Date.now(), 0),
105
+ path: this.config.refreshTokenPath,
106
+ });
107
+ }
108
+
109
+ public clearCookies(res: Response): void {
110
+ res.clearCookie(tokenName, {
111
+ httpOnly: true,
112
+ secure: process.env.SECURE_COOKIE === 'true',
113
+ sameSite: 'lax',
114
+ });
115
+ res.clearCookie(refreshTokenName, {
73
116
  httpOnly: true,
74
117
  secure: process.env.SECURE_COOKIE === 'true',
75
118
  sameSite: 'lax',
119
+ path: this.config.refreshTokenPath,
76
120
  });
77
121
  }
78
122
  }
@@ -32,8 +32,8 @@ export default class GoogleAuthController {
32
32
  throw new UnauthorizedException(`User is not authorized`);
33
33
  }
34
34
 
35
- const loginResult = await this.authService.login(req.user);
36
- this.authService.setCookie(res, loginResult.accessToken);
35
+ const tokens = await this.authService.login(req.user);
36
+ this.authService.setCookie(res, tokens);
37
37
  res.redirect('/');
38
38
  }
39
39
  }
@@ -26,8 +26,8 @@ export default class LocalAuthController {
26
26
  if (!req.user) {
27
27
  throw new UnauthorizedException(`User is not authorized`);
28
28
  }
29
- const loginResult = await this.authService.login(req.user);
30
- this.authService.setCookie(res, loginResult.accessToken);
31
- return loginResult;
29
+ const tokens = await this.authService.login(req.user);
30
+ this.authService.setCookie(res, tokens);
31
+ return tokens;
32
32
  }
33
33
  }
@@ -1,8 +1,17 @@
1
+ import { TJWTPayload, TJWTRefreshPayload } from "../../jwt-auth";
2
+
1
3
  export class LocalAuthLoginDto {
2
4
  email: string;
3
5
  password: string;
4
6
  }
5
7
 
6
8
  export class LocalAuthTokenDto {
7
- accessToken: string;
9
+ accessToken: {
10
+ token: string;
11
+ payload: TJWTPayload;
12
+ };
13
+ refreshToken: {
14
+ token: string;
15
+ payload: TJWTRefreshPayload;
16
+ }
8
17
  }
@@ -33,8 +33,8 @@ export default class MsAuthController {
33
33
  throw new UnauthorizedException(`User is not authorized`);
34
34
  }
35
35
 
36
- const loginResult = await this.authService.login(req.user);
37
- this.authService.setCookie(res, loginResult.accessToken);
36
+ const tokens = await this.authService.login(req.user);
37
+ this.authService.setCookie(res, tokens);
38
38
  res.redirect('/');
39
39
  }
40
40
  }
@@ -32,8 +32,8 @@ export default class YandexAuthController {
32
32
  throw new UnauthorizedException(`User is not authorized`);
33
33
  }
34
34
 
35
- const loginResult = await this.authService.login(req.user);
36
- this.authService.setCookie(res, loginResult.accessToken);
35
+ const tokens = await this.authService.login(req.user);
36
+ this.authService.setCookie(res, tokens);
37
37
  res.redirect('/');
38
38
  }
39
39
  }
@@ -1,8 +1,11 @@
1
1
  import { Injectable } from '@nestjs/common';
2
2
  import { ConfigService } from '@nestjs/config';
3
+ import { StringValue } from "ms";
3
4
 
4
5
  export type TJWTAuthConfig = {
5
6
  jwtSecret: string;
7
+ accessTokenExpiration: StringValue;
8
+ refreshTokenExpiration: StringValue;
6
9
  };
7
10
 
8
11
  @Injectable()
@@ -11,14 +14,14 @@ export class JWTAuthConfigService {
11
14
  }
12
15
 
13
16
  get config(): TJWTAuthConfig {
14
- const jwtSecret = this.configService.get<string>('JWT_SECRET');
15
-
16
- if (!jwtSecret) {
17
- throw new Error('JWT_SECRET is not defined');
18
- }
17
+ const jwtSecret = this.configService.getOrThrow<string>('JWT_SECRET');
18
+ const accessTokenExpiration = this.configService.get<StringValue>('ACCESS_TOKEN_EXPIRATION', '1h');
19
+ const refreshTokenExpiration = this.configService.get<StringValue>('REFRESH_TOKEN_EXPIRATION', '7d');
19
20
 
20
21
  return {
21
22
  jwtSecret,
23
+ accessTokenExpiration,
24
+ refreshTokenExpiration,
22
25
  };
23
26
  }
24
27
  }
@@ -9,6 +9,7 @@ import { JWTAuthConfigService } from "./jwt-auth.config";
9
9
  import { PassportModule } from "@nestjs/passport";
10
10
  import { JWTAuthService } from "./jwt-auth.service";
11
11
  import { RevokedTokensModule } from '../revoked-tokens';
12
+ import { JwtAuthRefreshStrategy } from "./jwt-auth.refresh.srtategy";
12
13
 
13
14
  @Module({})
14
15
  export class JwtAuthModule {
@@ -38,6 +39,7 @@ export class JwtAuthModule {
38
39
  providers: [
39
40
  JWTAuthConfigService,
40
41
  JwtAuthStrategy,
42
+ JwtAuthRefreshStrategy,
41
43
  JWTAuthService,
42
44
  {
43
45
  provide: APP_GUARD,
@@ -0,0 +1,7 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { AuthGuard } from '@nestjs/passport';
3
+ import { JWT_AUTH_REFRESH } from '../auth/auth-strategies.constants';
4
+
5
+ @Injectable()
6
+ export class JwtAuthRefreshGuard extends AuthGuard(JWT_AUTH_REFRESH) {
7
+ }
@@ -0,0 +1,85 @@
1
+ import { ExtractJwt, Strategy } from 'passport-jwt';
2
+ import { PassportStrategy } from '@nestjs/passport';
3
+ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
4
+ import { JWTAuthConfigService } from './jwt-auth.config';
5
+ import { Request } from 'express';
6
+ import { JWT_AUTH_REFRESH } from '../auth/auth-strategies.constants';
7
+ import { refreshTokenName } from "../auth/auth.constants";
8
+ import { UsersService } from '../users';
9
+ import { RevokedTokensService, TOKEN_TYPE } from '../revoked-tokens';
10
+ import { TJWTRefreshPayload } from "./jwt-auth.srtategy";
11
+ import ms from "ms";
12
+
13
+ @Injectable()
14
+ export class JwtAuthRefreshStrategy extends PassportStrategy(Strategy, JWT_AUTH_REFRESH) {
15
+ private readonly logger = new Logger(JwtAuthRefreshStrategy.name);
16
+
17
+ constructor(
18
+ private usersService: UsersService,
19
+ private jwtAuthConfigService: JWTAuthConfigService,
20
+ private revokedTokensService: RevokedTokensService,
21
+ ) {
22
+ super({
23
+ jwtFromRequest: ExtractJwt.fromExtractors([
24
+ ExtractJwt.fromAuthHeaderAsBearerToken(),
25
+ (req: Request) => {
26
+ const cookies = req.headers.cookie?.split('; ');
27
+ if (!cookies) {
28
+ return null;
29
+ }
30
+
31
+ const cookie = cookies.find(c => c.startsWith(`${refreshTokenName}=`));
32
+ if (!cookie) {
33
+ return null;
34
+ }
35
+
36
+ return cookie.split('=')[1];
37
+ },
38
+ ]),
39
+ ignoreExpiration: false,
40
+ secretOrKey: jwtAuthConfigService.config.jwtSecret,
41
+ passReqToCallback: false,
42
+ });
43
+ }
44
+
45
+ async validate(payload: TJWTRefreshPayload) {
46
+ this.logger.debug(`Validating refresh token for user with id: ${payload.sub}`);
47
+
48
+ // Check if a token has been revoked
49
+ if (!payload.jti) {
50
+ this.logger.error('Refresh token payload does not contain JTI');
51
+ throw new UnauthorizedException('Invalid token payload');
52
+ }
53
+
54
+ const isRevoked = await this.revokedTokensService.isTokenRevoked(payload.jti, payload.sid);
55
+ if (isRevoked) {
56
+ this.logger.debug(`Refresh token with JTI ${payload.jti} has already been revoked`);
57
+ throw new UnauthorizedException('Token has been revoked');
58
+ } else {
59
+ // Refresh token is valid, we revoke it to prevent reuse
60
+ this.logger.debug(`Revoking refresh token with JTI ${payload.jti}`);
61
+ await this.revokedTokensService.revokeRefreshToken({
62
+ jti: payload.jti,
63
+ expiresAt: new Date(
64
+ payload.exp
65
+ ? payload.exp * 1000
66
+ : Date.now() + ms(this.jwtAuthConfigService.config.refreshTokenExpiration)
67
+ ),
68
+ sid: payload.sid || null,
69
+ tokenType: TOKEN_TYPE.REFRESH,
70
+ });
71
+ }
72
+
73
+ const userId = payload.sub;
74
+ const user = await this.usersService.findOne({
75
+ select: ['id', 'email', 'role', 'permissions'],
76
+ where: { id: userId },
77
+ });
78
+
79
+ if (!user) {
80
+ throw new UnauthorizedException();
81
+ }
82
+
83
+ return user;
84
+ }
85
+ }
@@ -1,34 +1,59 @@
1
1
  import { Injectable } from "@nestjs/common";
2
2
  import { JwtService } from "@nestjs/jwt";
3
- import { TJWTPayload } from "./jwt-auth.srtategy";
4
- import { RevokedTokensService } from '../revoked-tokens';
3
+ import { TJWTPayload, TJWTRefreshPayload } from "./jwt-auth.srtategy";
4
+ import { RevokedToken, RevokedTokensService, TOKEN_TYPE, TRevokedToken } from '../revoked-tokens';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
+ import { JWTAuthConfigService, TJWTAuthConfig } from "./jwt-auth.config";
7
+ import { JwtSignOptions } from "@nestjs/jwt/dist/interfaces";
8
+ import ms from 'ms';
6
9
 
7
10
  @Injectable()
8
11
  export class JWTAuthService {
12
+ private readonly config: TJWTAuthConfig;
13
+
9
14
  constructor(
10
15
  private readonly jwtService: JwtService,
11
16
  private revokedTokensService: RevokedTokensService,
17
+ private readonly jwtAuthConfig: JWTAuthConfigService,
12
18
  ) {
19
+ this.config = this.jwtAuthConfig.config;
20
+ }
21
+
22
+ public generateJwtId(): string {
23
+ return uuidv4();
13
24
  }
14
25
 
15
- sign(payload: TJWTPayload): string {
16
- return this.jwtService.sign(payload, {
17
- expiresIn: '1h',
18
- jwtid: uuidv4(),
19
- });
26
+ public sign<PayloadType extends TJWTPayload | TJWTRefreshPayload>(
27
+ payload: PayloadType,
28
+ tokenType: TOKEN_TYPE,
29
+ ) {
30
+ const expiresIn = tokenType === TOKEN_TYPE.ACCESS
31
+ ? this.config.accessTokenExpiration
32
+ : this.config.refreshTokenExpiration;
33
+ const options: JwtSignOptions = {
34
+ expiresIn,
35
+ jwtid: this.generateJwtId(),
36
+ }
37
+ return {
38
+ token: this.jwtService.sign(payload, options),
39
+ payload: {
40
+ ...payload,
41
+ exp: Math.floor((ms(expiresIn) + Date.now()) / 1000),
42
+ jti: options.jwtid,
43
+ sid: payload.sid,
44
+ } as PayloadType,
45
+ };
20
46
  }
21
47
 
22
- decode<T = TJWTPayload>(token: string): T {
48
+ public decode<T = TJWTPayload>(token: string): T {
23
49
  return this.jwtService.decode(token) as T;
24
50
  }
25
51
 
26
52
  /**
27
53
  * Revoke a JWT token by its JTI
28
- * @param jti The JWT token identifier
29
- * @param expiresAt When the token naturally expires
54
+ * @param token The token to revoke
30
55
  */
31
- async revokeToken(jti: string, expiresAt: Date) {
32
- return this.revokedTokensService.revokeToken(jti, expiresAt);
56
+ public async revokeToken(token: TRevokedToken) {
57
+ return this.revokedTokensService.revokeToken(token, this.config.refreshTokenExpiration);
33
58
  }
34
59
  }
@@ -13,8 +13,17 @@ export type TJWTPayload = {
13
13
  iat?: string; // Issued At
14
14
  exp?: number; // Expiration Time
15
15
  jti?: string; // JWT ID, used for revocation
16
+ sid?: string; // Session ID to identify the family of tokens
16
17
  };
17
18
 
19
+ export type TJWTRefreshPayload = {
20
+ sub: string; // User ID
21
+ jti?: string; // JWT ID, used for revocation
22
+ iat?: number; // Issued At
23
+ exp?: number; // Expiration Time
24
+ sid?: string; // Session ID to identify the family of tokens
25
+ }
26
+
18
27
  @Injectable()
19
28
  export class JwtAuthStrategy extends PassportStrategy(Strategy, JWT_AUTH) {
20
29
  private readonly logger = new Logger(JwtAuthStrategy.name);
@@ -51,7 +60,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, JWT_AUTH) {
51
60
 
52
61
  // Check if a token has been revoked
53
62
  if (payload.jti) {
54
- const isRevoked = await this.revokedTokensService.isTokenRevoked(payload.jti);
63
+ const isRevoked = await this.revokedTokensService.isTokenRevoked(payload.jti, payload.sid);
55
64
  if (isRevoked) {
56
65
  this.logger.debug(`Token with JTI ${payload.jti} has been revoked`);
57
66
  throw new UnauthorizedException('Token has been revoked');
@@ -1,14 +1,38 @@
1
1
  import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
2
2
 
3
+ export enum TOKEN_TYPE {
4
+ ACCESS = 'access',
5
+ REFRESH = 'refresh',
6
+ SESSION = 'session',
7
+ }
8
+
3
9
  @Entity('revoked_tokens')
10
+ @Index('IDX_REVOKED_TOKEN_SID_TYPE', ['sid', 'tokenType'])
4
11
  export class RevokedToken {
5
12
  @PrimaryGeneratedColumn('uuid')
6
13
  id: string;
7
14
 
8
- @Column({ unique: true })
15
+ @Column({
16
+ unique: true,
17
+ type: 'uuid',
18
+ })
9
19
  @Index('IDX_REVOKED_TOKEN_JTI')
10
20
  jti: string;
11
21
 
22
+ @Column({
23
+ type: 'uuid',
24
+ nullable: true,
25
+ default: null,
26
+ })
27
+ sid: string | null;
28
+
29
+ @Column({
30
+ type: 'enum',
31
+ enum: TOKEN_TYPE,
32
+ default: TOKEN_TYPE.ACCESS,
33
+ })
34
+ tokenType: TOKEN_TYPE;
35
+
12
36
  @Column({
13
37
  name: 'expires_at',
14
38
  type: 'timestamp with time zone',
@@ -22,3 +46,5 @@ export class RevokedToken {
22
46
  })
23
47
  createdAt: Date;
24
48
  }
49
+
50
+ export type TRevokedToken = Omit<RevokedToken, 'id' | 'createdAt'>;
@@ -1,7 +1,9 @@
1
1
  import { Injectable, Logger } from '@nestjs/common';
2
2
  import { Repository } from 'typeorm';
3
- import { RevokedToken } from './entities/revoked-token.entity';
3
+ import { RevokedToken, TOKEN_TYPE, TRevokedToken } from './entities/revoked-token.entity';
4
4
  import { Cron, CronExpression } from "@nestjs/schedule";
5
+ import ms, { StringValue } from "ms";
6
+ import { FindOptionsWhere } from "typeorm/find-options/FindOptionsWhere";
5
7
 
6
8
  @Injectable()
7
9
  export class RevokedTokensService {
@@ -11,31 +13,59 @@ export class RevokedTokensService {
11
13
 
12
14
  /**
13
15
  * Revokes a JWT token by storing its JTI in the database
14
- * @param jti The JWT token identifier
15
- * @param expiresAt When the token naturally expires
16
+ * @param token The token to revoke, containing at least the JTI and expiration date
17
+ * @param refreshTokenExpiration The expiration time for the refresh token. We use it to set the expiration date of
18
+ * the session token.
16
19
  */
17
- async revokeToken(jti: string, expiresAt: Date): Promise<void> {
18
- await this.revokedTokenRepository
20
+ public async revokeToken(token: TRevokedToken, refreshTokenExpiration: StringValue): Promise<void> {
21
+ const tokens: TRevokedToken[] = [token];
22
+
23
+ if (token.sid) {
24
+ tokens.push({
25
+ jti: token.sid,
26
+ sid: token.sid,
27
+ expiresAt: new Date(Date.now() + ms(refreshTokenExpiration)),
28
+ tokenType: TOKEN_TYPE.SESSION,
29
+ });
30
+ }
31
+
32
+ await this.revokeTokens(tokens);
33
+ }
34
+
35
+ public async revokeRefreshToken(token: TRevokedToken): Promise<void> {
36
+ await this.revokeTokens([token]);
37
+ }
38
+
39
+ private revokeTokens(tokens: TRevokedToken[]): Promise<void> {
40
+ return this.revokedTokenRepository
19
41
  .createQueryBuilder()
20
42
  .insert()
21
43
  .into(RevokedToken)
22
- .values({ jti, expiresAt })
44
+ .values(tokens)
23
45
  .orIgnore()
24
- .execute();
25
-
26
- this.logger.debug(`Token with JTI ${jti} has been revoked`);
46
+ .execute()
47
+ .then(() => {
48
+ this.logger.debug(`Tokens with JTI ${tokens.map(t => t.jti).join(', ')} have been revoked`);
49
+ });
27
50
  }
28
51
 
29
52
  /**
30
53
  * Checks if a token has been revoked
31
54
  * @param jti The JWT token identifier
55
+ * @param sid Optional session identifier, used for session tokens
32
56
  * @returns true if the token is revoked, false otherwise
33
57
  */
34
- async isTokenRevoked(jti: string): Promise<boolean> {
35
- const token = await this.revokedTokenRepository.findOne({
36
- where: { jti },
58
+ public async isTokenRevoked(jti: string, sid?: string): Promise<boolean> {
59
+ const whereConditions: FindOptionsWhere<RevokedToken>[] = [{ jti }];
60
+
61
+ if (sid) {
62
+ whereConditions.push({ sid, tokenType: TOKEN_TYPE.SESSION });
63
+ }
64
+
65
+ const tokensCount = await this.revokedTokenRepository.count({
66
+ where: whereConditions,
37
67
  });
38
- return !!token;
68
+ return tokensCount > 0;
39
69
  }
40
70
 
41
71
  @Cron(CronExpression.EVERY_HOUR)