Skip to content

Commit 17506aa

Browse files
authored
Merge pull request #622 from talatu4sambo-cmyk/feature/510-rotate-refresh-tokens-on-use-staging
feat(auth): implement refresh token rotation on use
2 parents 576c28b + 557b10a commit 17506aa

5 files changed

Lines changed: 366 additions & 1 deletion

File tree

prisma/schema.prisma

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ model User {
130130
/// Relation to user's spending limits
131131
spendingLimits SpendingLimit[]
132132
133+
/// Relation to user's refresh tokens
134+
refreshTokens RefreshToken[]
135+
133136
@@index([authId])
134137
@@index([authProvider])
135138
@@index([deletedAt])
@@ -736,6 +739,58 @@ model KeyRotationAuditLog {
736739
@@index([expiresAt])
737740
}
738741

742+
/// Refresh token status lifecycle
743+
enum RefreshTokenStatus {
744+
ACTIVE
745+
ROTATED
746+
REVOKED
747+
EXPIRED
748+
}
749+
750+
/// Refresh token management with automatic rotation on use
751+
model RefreshToken {
752+
id String @id @default(uuid())
753+
754+
/// User reference
755+
userId String
756+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
757+
758+
/// Refresh token hash (never store plaintext)
759+
tokenHash String @unique
760+
761+
/// Rotation chain - previous token in rotation sequence
762+
previousTokenId String?
763+
previousToken RefreshToken? @relation("TokenRotation", fields: [previousTokenId], references: [id])
764+
nextToken RefreshToken[] @relation("TokenRotation")
765+
766+
/// Token status
767+
status RefreshTokenStatus @default(ACTIVE)
768+
769+
/// Expiration timestamp
770+
expiresAt DateTime
771+
772+
/// Usage tracking
773+
lastUsedAt DateTime?
774+
usageCount Int @default(0)
775+
776+
/// Rotation tracking
777+
rotatedAt DateTime?
778+
rotatedReason String?
779+
780+
/// Revocation details
781+
revokedAt DateTime?
782+
revokeReason String?
783+
784+
/// Metadata
785+
createdAt DateTime @default(now())
786+
updatedAt DateTime @updatedAt
787+
788+
@@index([userId, status])
789+
@@index([tokenHash])
790+
@@index([expiresAt])
791+
@@index([previousTokenId])
792+
}
793+
739794
/// Transaction lifecycle states
740795
enum TransactionStatus {
741796
PENDING // Transaction created but not yet submitted to network

src/auth/auth-orchestrator.controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ import {
4141
@FeatureFlag('auth_api')
4242
@UseGuards(FeatureFlagGuard)
4343
export class AuthOrchestratorController {
44-
constructor(private readonly authOrchestrator: AuthOrchestrator) {}
44+
constructor(
45+
private readonly authOrchestrator: AuthOrchestrator,
46+
private readonly refreshTokenService: RefreshTokenService,
47+
) {}
4548

4649
/**
4750
* Main authentication endpoint - handles both first-time and returning users.

src/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { FeatureFlagGuard } from '../common/feature-flags/feature-flag.guard';
1818
controllers: [AuthOrchestratorController, AuthMetricsController],
1919
providers: [
2020
AuthOrchestrator,
21+
RefreshTokenService,
2122
IdempotencyService,
2223
AuthRateLimitService,
2324
AuthRateLimitGuard,
@@ -27,6 +28,7 @@ import { FeatureFlagGuard } from '../common/feature-flags/feature-flag.guard';
2728
],
2829
exports: [
2930
AuthOrchestrator,
31+
RefreshTokenService,
3032
IdempotencyService,
3133
AuthRateLimitService,
3234
AuthRateLimitGuard,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { RefreshTokenService } from './refresh-token.service';
3+
import { PrismaService } from '../common/prisma/prisma.service';
4+
import { RefreshTokenStatus } from '../generated/prisma';
5+
6+
describe('RefreshTokenService', () => {
7+
let service: RefreshTokenService;
8+
let prismaMock: any;
9+
10+
beforeEach(async () => {
11+
prismaMock = {
12+
refreshToken: {
13+
create: jest.fn(),
14+
update: jest.fn(),
15+
updateMany: jest.fn(),
16+
findUnique: jest.fn(),
17+
findMany: jest.fn(),
18+
},
19+
};
20+
21+
const module: TestingModule = await Test.createTestingModule({
22+
providers: [
23+
RefreshTokenService,
24+
{ provide: PrismaService, useValue: prismaMock },
25+
],
26+
}).compile();
27+
28+
service = module.get<RefreshTokenService>(RefreshTokenService);
29+
});
30+
31+
describe('createRefreshToken', () => {
32+
it('should create a new refresh token', async () => {
33+
const request = {
34+
userId: 'user-1',
35+
tokenHash: 'hash-1',
36+
expiresAt: new Date(Date.now() + 7200000),
37+
};
38+
39+
prismaMock.refreshToken.create.mockResolvedValue({
40+
...request,
41+
status: RefreshTokenStatus.ACTIVE,
42+
id: 'token-1',
43+
});
44+
45+
const result = await service.createRefreshToken(request);
46+
expect(result.status).toBe(RefreshTokenStatus.ACTIVE);
47+
expect(prismaMock.refreshToken.create).toHaveBeenCalled();
48+
});
49+
});
50+
51+
describe('rotateRefreshToken', () => {
52+
it('should rotate token and create new one', async () => {
53+
const currentToken = {
54+
id: 'token-1',
55+
userId: 'user-1',
56+
tokenHash: 'hash-1',
57+
status: RefreshTokenStatus.ACTIVE,
58+
expiresAt: new Date(Date.now() + 7200000),
59+
};
60+
61+
prismaMock.refreshToken.findUnique.mockResolvedValue(currentToken);
62+
prismaMock.refreshToken.update.mockResolvedValue({
63+
...currentToken,
64+
status: RefreshTokenStatus.ROTATED,
65+
});
66+
prismaMock.refreshToken.create.mockResolvedValue({
67+
id: 'token-2',
68+
userId: 'user-1',
69+
tokenHash: 'hash-2',
70+
previousTokenId: 'token-1',
71+
status: RefreshTokenStatus.ACTIVE,
72+
});
73+
74+
const result = await service.rotateRefreshToken({
75+
currentTokenHash: 'hash-1',
76+
newTokenHash: 'hash-2',
77+
expiresAt: new Date(Date.now() + 7200000),
78+
});
79+
80+
expect(result.status).toBe(RefreshTokenStatus.ACTIVE);
81+
expect(prismaMock.refreshToken.update).toHaveBeenCalled();
82+
expect(prismaMock.refreshToken.create).toHaveBeenCalled();
83+
});
84+
85+
it('should throw when current token is expired', async () => {
86+
const expiredToken = {
87+
id: 'token-1',
88+
userId: 'user-1',
89+
status: RefreshTokenStatus.ACTIVE,
90+
expiresAt: new Date(Date.now() - 1000),
91+
};
92+
93+
prismaMock.refreshToken.findUnique.mockResolvedValue(expiredToken);
94+
95+
await expect(
96+
service.rotateRefreshToken({
97+
currentTokenHash: 'hash-1',
98+
newTokenHash: 'hash-2',
99+
expiresAt: new Date(Date.now() + 7200000),
100+
}),
101+
).rejects.toThrow('Current refresh token has expired');
102+
});
103+
});
104+
105+
describe('revokeRefreshToken', () => {
106+
it('should revoke a refresh token', async () => {
107+
const tokenHash = 'hash-1';
108+
const revokedAt = new Date();
109+
110+
prismaMock.refreshToken.update.mockResolvedValue({
111+
tokenHash,
112+
status: RefreshTokenStatus.REVOKED,
113+
revokedAt,
114+
});
115+
116+
const result = await service.revokeRefreshToken({ tokenHash });
117+
expect(result.status).toBe(RefreshTokenStatus.REVOKED);
118+
});
119+
});
120+
121+
describe('validateRefreshToken', () => {
122+
it('should return null for invalid token', async () => {
123+
prismaMock.refreshToken.findUnique.mockResolvedValue(null);
124+
const result = await service.validateRefreshToken('invalid-hash');
125+
expect(result).toBeNull();
126+
});
127+
128+
it('should return valid token', async () => {
129+
const token = {
130+
id: 'token-1',
131+
tokenHash: 'hash-1',
132+
status: RefreshTokenStatus.ACTIVE,
133+
expiresAt: new Date(Date.now() + 7200000),
134+
};
135+
136+
prismaMock.refreshToken.findUnique.mockResolvedValue(token);
137+
const result = await service.validateRefreshToken('hash-1');
138+
expect(result).toEqual(token);
139+
});
140+
});
141+
});

0 commit comments

Comments
 (0)