Skip to content

Commit dc8577a

Browse files
No-bodyqVox-d-glitch
authored andcommitted
feat: back access-token revocation with Redis blacklist
Rebuilds #976 on top of the current wallet-auth architecture: wires the app's existing RedisService (config/redis.module.ts) into a new TokenBlacklistService, and connects it to JwtAuthGuard (fixing its previously-unresolvable blacklistCheck constructor param) and to AuthService.logout(), so a revoked access token's jti is rejected on every subsequent request, across restarts and server instances.
1 parent da2571d commit dc8577a

8 files changed

Lines changed: 112 additions & 9 deletions

File tree

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { AppController } from './app.controller.js';
66
import { AppService } from './app.service.js';
77
import { UsersModule } from './users/users.module.js';
88
import { AuthModule } from './auth/auth.module.js';
9+
import { RedisModule } from './config/redis.module.js';
910
import typeOrmConfig from './config/typeorm.config.js';
1011

1112
@Module({
1213
imports: [
1314
ConfigModule.forRoot({ isGlobal: true }),
1415
TypeOrmModule.forRoot(typeOrmConfig),
1516
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
17+
RedisModule.forRoot(),
1618
UsersModule,
1719
AuthModule,
1820
],

backend/src/auth/auth.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ export class AuthController {
133133
@ApiOperation({ summary: 'Logout and invalidate current session', description: 'Revokes the current access token and optional refresh token.' })
134134
@ApiResponse({ status: 200, description: 'Logged out successfully' })
135135
@ApiResponse({ status: 401, description: 'Authentication required' })
136-
logout(@Request() req: { user?: { jti?: string } }) {
137-
this.authService.logout(req.user?.jti || '');
136+
async logout(@Request() req: { user?: { jti?: string; exp?: number } }) {
137+
await this.authService.logout(req.user?.jti || '', req.user?.exp);
138138
return { message: 'Logged out successfully' };
139139
}
140140

backend/src/auth/auth.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
88
import { WalletStrategy } from './strategies/wallet.strategy';
99
import { JwtAuthGuard } from './guards/jwt-auth.guard';
1010
import { RolesGuard } from './guards/roles.guard';
11+
import { TokenBlacklistService } from './services/token-blacklist.service';
1112

1213
/**
1314
* #971: Self-contained Auth module.
@@ -30,7 +31,7 @@ import { RolesGuard } from './guards/roles.guard';
3031
}),
3132
],
3233
controllers: [AuthController],
33-
providers: [AuthService, JwtStrategy, WalletStrategy, JwtAuthGuard, RolesGuard],
34-
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
34+
providers: [AuthService, JwtStrategy, WalletStrategy, JwtAuthGuard, RolesGuard, TokenBlacklistService],
35+
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule, TokenBlacklistService],
3536
})
3637
export class AuthModule {}

backend/src/auth/auth.service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
44
import { v4 as uuidv4 } from 'uuid';
55
import { JwtAccessTokenPayload } from '../interfaces/jwt-payload.interface';
66
import { WalletStrategy } from '../strategies/wallet.strategy';
7+
import { TokenBlacklistService } from './services/token-blacklist.service';
78

89
/**
910
* #971-978: Auth service handling wallet login, JWT lifecycle, and session management.
@@ -41,6 +42,7 @@ export class AuthService {
4142
private readonly jwtService: JwtService,
4243
private readonly configService: ConfigService,
4344
private readonly walletStrategy: WalletStrategy,
45+
private readonly tokenBlacklistService: TokenBlacklistService,
4446
) {}
4547

4648
/**
@@ -170,10 +172,18 @@ export class AuthService {
170172

171173
/**
172174
* #976: Logout — revoke both access and refresh tokens.
175+
*
176+
* The access token jti is blacklisted in Redis (rather than only the
177+
* in-process Set) so revocation survives restarts and applies across
178+
* every server instance.
173179
*/
174-
logout(accessTokenJti: string, refreshJti?: string): void {
180+
async logout(accessTokenJti: string, accessTokenExp?: number, refreshJti?: string): Promise<void> {
175181
if (accessTokenJti) {
176182
this.revokedAccessTokens.add(accessTokenJti);
183+
const ttlSeconds = accessTokenExp
184+
? accessTokenExp - Math.floor(Date.now() / 1000)
185+
: parseInt(this.configService.get('JWT_ACCESS_TTL', '900'), 10);
186+
await this.tokenBlacklistService.blacklist(accessTokenJti, ttlSeconds);
177187
}
178188
if (refreshJti) {
179189
const record = this.refreshTokens.get(refreshJti);

backend/src/auth/guards/jwt-auth.guard.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ConfigService } from '@nestjs/config';
88
import { JwtService } from '@nestjs/jwt';
99
import { Request } from 'express';
1010
import { JwtAccessTokenPayload } from '../interfaces/jwt-payload.interface';
11+
import { TokenBlacklistService } from '../services/token-blacklist.service';
1112

1213
/**
1314
* #981: Enhanced JWT Auth Guard.
@@ -26,7 +27,7 @@ export class JwtAuthGuard implements CanActivate {
2627
constructor(
2728
private readonly jwtService: JwtService,
2829
private readonly configService: ConfigService,
29-
private readonly blacklistCheck?: (jti: string) => Promise<boolean>,
30+
private readonly tokenBlacklistService: TokenBlacklistService,
3031
) {}
3132

3233
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -46,8 +47,8 @@ export class JwtAuthGuard implements CanActivate {
4647
});
4748

4849
// #981: Check Redis blacklist for revoked tokens
49-
if (this.blacklistCheck && payload.jti) {
50-
const isRevoked = await this.blacklistCheck(payload.jti);
50+
if (payload.jti) {
51+
const isRevoked = await this.tokenBlacklistService.isBlacklisted(payload.jti);
5152
if (isRevoked) {
5253
throw new UnauthorizedException({
5354
message: 'Token has been revoked',
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { TokenBlacklistService } from './token-blacklist.service.js';
3+
import { RedisService } from '../../config/redis.module.js';
4+
5+
describe('TokenBlacklistService', () => {
6+
let service: TokenBlacklistService;
7+
8+
const mockRedisService = {
9+
set: jest.fn(),
10+
exists: jest.fn(),
11+
};
12+
13+
beforeEach(async () => {
14+
const module: TestingModule = await Test.createTestingModule({
15+
providers: [
16+
TokenBlacklistService,
17+
{ provide: RedisService, useValue: mockRedisService },
18+
],
19+
}).compile();
20+
21+
service = module.get<TokenBlacklistService>(TokenBlacklistService);
22+
});
23+
24+
afterEach(() => {
25+
jest.clearAllMocks();
26+
});
27+
28+
describe('blacklist', () => {
29+
it('should store the jti with the given TTL', async () => {
30+
await service.blacklist('jti-1', 900);
31+
expect(mockRedisService.set).toHaveBeenCalledWith('blacklist:jti-1', '1', 900);
32+
});
33+
34+
it('should not store anything for a non-positive TTL', async () => {
35+
await service.blacklist('jti-1', 0);
36+
expect(mockRedisService.set).not.toHaveBeenCalled();
37+
});
38+
39+
it('should not store anything for an empty jti', async () => {
40+
await service.blacklist('', 900);
41+
expect(mockRedisService.set).not.toHaveBeenCalled();
42+
});
43+
});
44+
45+
describe('isBlacklisted', () => {
46+
it('should return true when the key exists', async () => {
47+
mockRedisService.exists.mockResolvedValue(true);
48+
const result = await service.isBlacklisted('jti-1');
49+
expect(result).toBe(true);
50+
expect(mockRedisService.exists).toHaveBeenCalledWith('blacklist:jti-1');
51+
});
52+
53+
it('should return false when the key does not exist', async () => {
54+
mockRedisService.exists.mockResolvedValue(false);
55+
const result = await service.isBlacklisted('jti-1');
56+
expect(result).toBe(false);
57+
});
58+
59+
it('should return false for an empty jti without querying Redis', async () => {
60+
const result = await service.isBlacklisted('');
61+
expect(result).toBe(false);
62+
expect(mockRedisService.exists).not.toHaveBeenCalled();
63+
});
64+
});
65+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { RedisService } from '../../config/redis.module.js';
3+
4+
/**
5+
* #976: Redis-backed access token blacklist.
6+
*
7+
* Persists revoked jti's beyond process memory so logout invalidation
8+
* survives restarts and works across multiple server instances.
9+
*/
10+
@Injectable()
11+
export class TokenBlacklistService {
12+
constructor(private readonly redisService: RedisService) {}
13+
14+
async blacklist(jti: string, ttlSeconds: number): Promise<void> {
15+
if (!jti || ttlSeconds <= 0) return;
16+
await this.redisService.set(`blacklist:${jti}`, '1', ttlSeconds);
17+
}
18+
19+
async isBlacklisted(jti: string): Promise<boolean> {
20+
if (!jti) return false;
21+
return this.redisService.exists(`blacklist:${jti}`);
22+
}
23+
}

backend/src/users/users.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MentorProfile } from './entities/mentor-profile.entity.js';
1010
import { MenteeProfile } from './entities/mentee-profile.entity.js';
1111
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard.js';
1212
import { RolesGuard } from '../auth/guards/roles.guard.js';
13+
import { TokenBlacklistService } from '../auth/services/token-blacklist.service.js';
1314

1415
@Module({
1516
imports: [
@@ -25,7 +26,7 @@ import { RolesGuard } from '../auth/guards/roles.guard.js';
2526
ConfigModule,
2627
],
2728
controllers: [UsersController],
28-
providers: [UsersService, JwtAuthGuard, RolesGuard],
29+
providers: [UsersService, JwtAuthGuard, RolesGuard, TokenBlacklistService],
2930
exports: [UsersService],
3031
})
3132
export class UsersModule {}

0 commit comments

Comments
 (0)