Skip to content

Commit fc4f881

Browse files
authored
Merge pull request #708 from oyeyemidavid-gif/fix/atomic-refresh-token-rotation
fix: make refresh token rotation single-use under concurrency
2 parents 049013d + 9a770a9 commit fc4f881

2 files changed

Lines changed: 216 additions & 76 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { JwtService } from '@nestjs/jwt';
2+
import { ConfigService } from '@nestjs/config';
3+
import { AuthSessionService } from './auth-session.service';
4+
import { UserRole } from './enums/user-role.enum';
5+
6+
function createRedisMock() {
7+
const values = new Map<string, string>();
8+
const sets = new Map<string, Set<string>>();
9+
return {
10+
get: jest.fn(async (key: string) => values.get(key) ?? null),
11+
set: jest.fn(async (key: string, value: string) => {
12+
values.set(key, value);
13+
return 'OK';
14+
}),
15+
sadd: jest.fn(async (key: string, value: string) => {
16+
const set = sets.get(key) ?? new Set<string>();
17+
set.add(value);
18+
sets.set(key, set);
19+
return 1;
20+
}),
21+
smembers: jest.fn(async (key: string) => [...(sets.get(key) ?? [])]),
22+
srem: jest.fn(async () => 1),
23+
};
24+
}
25+
26+
describe('AuthSessionService refresh token concurrency', () => {
27+
it('accepts a refresh token at most once under concurrent requests', async () => {
28+
const redis = createRedisMock();
29+
const jwtService = {
30+
verifyAsync: jest.fn(async () => ({
31+
sub: 'user-1',
32+
role: UserRole.LEARNER,
33+
sessionId: 'session-1',
34+
})),
35+
signAsync: jest.fn(async () => 'new-token'),
36+
} as unknown as JwtService;
37+
const config = {
38+
get: jest.fn((_key: string, fallback: unknown) => fallback),
39+
} as unknown as ConfigService;
40+
const service = new AuthSessionService(jwtService, config, redis as any);
41+
const refreshToken = 'refresh-token';
42+
const refreshTokenHash = service['hashToken'](refreshToken);
43+
44+
await redis.set(
45+
'session:session-1',
46+
JSON.stringify({
47+
sessionId: 'session-1',
48+
userId: 'user-1',
49+
role: UserRole.LEARNER,
50+
refreshTokenHash,
51+
createdAt: new Date().toISOString(),
52+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
53+
revoked: false,
54+
}),
55+
);
56+
57+
const results = await Promise.allSettled([
58+
service.refreshTokens(refreshToken),
59+
service.refreshTokens(refreshToken),
60+
]);
61+
62+
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
63+
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
64+
expect((results.find((result) => result.status === 'rejected') as PromiseRejectedResult).reason.response).toMatchObject({
65+
error: 'SESSION_NOT_FOUND',
66+
});
67+
68+
const stored = JSON.parse((await redis.get('session:session-1'))!);
69+
expect(stored.revoked).toBe(true);
70+
});
71+
});

0 commit comments

Comments
 (0)