Skip to content

Commit 4a8b799

Browse files
authored
Merge pull request #623 from talatu4sambo-cmyk/feature/511-revoke-sessions-credential-change
feat(auth): implement session revocation on credential change
2 parents 17506aa + a3739c9 commit 4a8b799

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

src/auth/session.service.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { SessionService } from './session.service';
3+
import { PrismaService } from '../common/prisma/prisma.service';
4+
import { SessionStatus } from '../generated/prisma';
5+
6+
describe('SessionService', () => {
7+
let service: SessionService;
8+
let prismaMock: any;
9+
10+
beforeEach(async () => {
11+
prismaMock = {
12+
session: {
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+
SessionService,
24+
{ provide: PrismaService, useValue: prismaMock },
25+
],
26+
}).compile();
27+
28+
service = module.get<SessionService>(SessionService);
29+
});
30+
31+
describe('createSession', () => {
32+
it('should create a new session', async () => {
33+
const request = {
34+
userId: 'user-1',
35+
sessionToken: 'token-1',
36+
expiresAt: new Date(Date.now() + 3600000),
37+
};
38+
39+
prismaMock.session.create.mockResolvedValue({
40+
...request,
41+
status: SessionStatus.ACTIVE,
42+
id: 'session-1',
43+
});
44+
45+
const result = await service.createSession(request);
46+
expect(result.status).toBe(SessionStatus.ACTIVE);
47+
expect(prismaMock.session.create).toHaveBeenCalled();
48+
});
49+
});
50+
51+
describe('revokeSession', () => {
52+
it('should revoke a session', async () => {
53+
const sessionToken = 'token-1';
54+
const revokedAt = new Date();
55+
56+
prismaMock.session.update.mockResolvedValue({
57+
sessionToken,
58+
status: SessionStatus.REVOKED,
59+
revokedAt,
60+
revokeReason: 'User initiated revocation',
61+
});
62+
63+
const result = await service.revokeSession({ sessionToken });
64+
expect(result.status).toBe(SessionStatus.REVOKED);
65+
expect(prismaMock.session.update).toHaveBeenCalledWith(
66+
expect.objectContaining({
67+
where: { sessionToken },
68+
data: expect.objectContaining({ status: SessionStatus.REVOKED }),
69+
}),
70+
);
71+
});
72+
});
73+
74+
describe('revokeUserSessions', () => {
75+
it('should revoke all user sessions on credential change', async () => {
76+
const userId = 'user-1';
77+
78+
prismaMock.session.updateMany.mockResolvedValue({ count: 3 });
79+
80+
const result = await service.revokeUserSessions(userId);
81+
expect(result.count).toBe(3);
82+
expect(prismaMock.session.updateMany).toHaveBeenCalledWith(
83+
expect.objectContaining({
84+
where: {
85+
userId,
86+
status: SessionStatus.ACTIVE,
87+
},
88+
data: expect.objectContaining({
89+
status: SessionStatus.REVOKED,
90+
revokeReason: 'Sessions revoked on credential change',
91+
}),
92+
}),
93+
);
94+
});
95+
});
96+
97+
describe('validateSession', () => {
98+
it('should return null for invalid session', async () => {
99+
prismaMock.session.findUnique.mockResolvedValue(null);
100+
const result = await service.validateSession('invalid-token');
101+
expect(result).toBeNull();
102+
});
103+
104+
it('should return null for revoked session', async () => {
105+
prismaMock.session.findUnique.mockResolvedValue({
106+
sessionToken: 'token-1',
107+
status: SessionStatus.REVOKED,
108+
expiresAt: new Date(Date.now() + 3600000),
109+
});
110+
111+
const result = await service.validateSession('token-1');
112+
expect(result).toBeNull();
113+
});
114+
115+
it('should return session for valid token', async () => {
116+
const session = {
117+
id: 'session-1',
118+
sessionToken: 'token-1',
119+
status: SessionStatus.ACTIVE,
120+
expiresAt: new Date(Date.now() + 3600000),
121+
};
122+
123+
prismaMock.session.findUnique.mockResolvedValue(session);
124+
const result = await service.validateSession('token-1');
125+
expect(result).toEqual(session);
126+
});
127+
});
128+
});

src/auth/session.service.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { PrismaService } from '../common/prisma/prisma.service';
3+
import { SessionStatus } from '../generated/prisma';
4+
5+
export interface CreateSessionRequest {
6+
userId: string;
7+
sessionToken: string;
8+
expiresAt: Date;
9+
}
10+
11+
export interface RevokeSessionRequest {
12+
sessionToken: string;
13+
reason?: string;
14+
}
15+
16+
@Injectable()
17+
export class SessionService {
18+
private readonly logger = new Logger(SessionService.name);
19+
20+
constructor(private readonly prisma: PrismaService) {}
21+
22+
async createSession(request: CreateSessionRequest) {
23+
this.logger.log(`Creating session for user ${request.userId}`);
24+
return this.prisma.session.create({
25+
data: {
26+
userId: request.userId,
27+
sessionToken: request.sessionToken,
28+
expiresAt: request.expiresAt,
29+
status: SessionStatus.ACTIVE,
30+
},
31+
});
32+
}
33+
34+
async revokeSession(request: RevokeSessionRequest) {
35+
this.logger.log(`Revoking session: ${request.sessionToken}`);
36+
return this.prisma.session.update({
37+
where: { sessionToken: request.sessionToken },
38+
data: {
39+
status: SessionStatus.REVOKED,
40+
revokedAt: new Date(),
41+
revokeReason: request.reason || 'User initiated revocation',
42+
},
43+
});
44+
}
45+
46+
async revokeUserSessions(userId: string, reason?: string) {
47+
this.logger.log(`Revoking all sessions for user ${userId}`);
48+
return this.prisma.session.updateMany({
49+
where: {
50+
userId,
51+
status: SessionStatus.ACTIVE,
52+
},
53+
data: {
54+
status: SessionStatus.REVOKED,
55+
revokedAt: new Date(),
56+
revokeReason: reason || 'Sessions revoked on credential change',
57+
},
58+
});
59+
}
60+
61+
async getActiveSessions(userId: string) {
62+
return this.prisma.session.findMany({
63+
where: {
64+
userId,
65+
status: SessionStatus.ACTIVE,
66+
expiresAt: { gt: new Date() },
67+
},
68+
});
69+
}
70+
71+
async validateSession(sessionToken: string) {
72+
const session = await this.prisma.session.findUnique({
73+
where: { sessionToken },
74+
});
75+
76+
if (!session) return null;
77+
if (session.status !== SessionStatus.ACTIVE) return null;
78+
if (session.expiresAt < new Date()) return null;
79+
80+
return session;
81+
}
82+
}

0 commit comments

Comments
 (0)