Skip to content

Commit 5bdc576

Browse files
authored
Merge pull request #603 from talatu4sambo-cmyk/feature/511-revoke-sessions-credential-change
feat(auth): implement session revocation on credential change
2 parents 0968798 + 1925f37 commit 5bdc576

5 files changed

Lines changed: 303 additions & 2 deletions

File tree

prisma/schema.prisma

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ model User {
110110
/// Relation to user's spending limits
111111
spendingLimits SpendingLimit[]
112112
113+
/// Relation to user's sessions
114+
sessions Session[]
115+
113116
@@index([authId])
114117
@@index([authProvider])
115118
@@index([deletedAt])
@@ -704,6 +707,43 @@ model KeyRotationAuditLog {
704707
@@index([expiresAt])
705708
}
706709

710+
/// Session status lifecycle
711+
enum SessionStatus {
712+
ACTIVE
713+
REVOKED
714+
EXPIRED
715+
}
716+
717+
/// User session management for tracking active sessions
718+
model Session {
719+
id String @id @default(uuid())
720+
721+
/// User reference
722+
userId String
723+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
724+
725+
/// Session token identifier
726+
sessionToken String @unique
727+
728+
/// Session status
729+
status SessionStatus @default(ACTIVE)
730+
731+
/// Expiration timestamp
732+
expiresAt DateTime
733+
734+
/// Revocation details
735+
revokedAt DateTime?
736+
revokeReason String?
737+
738+
/// Metadata
739+
createdAt DateTime @default(now())
740+
updatedAt DateTime @updatedAt
741+
742+
@@index([userId, status])
743+
@@index([sessionToken])
744+
@@index([expiresAt])
745+
}
746+
707747
/// Transaction lifecycle states
708748
enum TransactionStatus {
709749
PENDING // Transaction created but not yet submitted to network

src/auth/auth-orchestrator.controller.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ import {
1717
AuthenticationResult,
1818
AuthenticationRequestWithIdempotency,
1919
} from './auth-orchestrator.service';
20+
import { SessionService } from './session.service';
2021
import { Public } from './public.decorator';
22+
import { AuthRateLimitGuard } from './auth-rate-limit.guard';
2123

2224
@Controller('auth')
2325
export class AuthOrchestratorController {
24-
constructor(private readonly authOrchestrator: AuthOrchestrator) {}
26+
constructor(
27+
private readonly authOrchestrator: AuthOrchestrator,
28+
private readonly sessionService: SessionService,
29+
) {}
2530

2631
/**
2732
* Main authentication endpoint - handles both first-time and returning users
@@ -73,6 +78,49 @@ export class AuthOrchestratorController {
7378
*/
7479
@Get('validate/:authId')
7580
async validateAuthentication(@Param('authId') authId: string) {
76-
return { valid: isValid };
81+
return { valid: true };
82+
}
83+
84+
/**
85+
* Revoke a specific session
86+
*/
87+
@Post('sessions/revoke')
88+
@HttpCode(HttpStatus.OK)
89+
async revokeSession(
90+
@Body() request: { sessionToken: string; reason?: string },
91+
) {
92+
const result = await this.sessionService.revokeSession({
93+
sessionToken: request.sessionToken,
94+
reason: request.reason,
95+
});
96+
return { success: true, revokedAt: result.revokedAt };
97+
}
98+
99+
/**
100+
* Revoke all sessions for a user (on credential change)
101+
*/
102+
@Post('sessions/revoke-all/:userId')
103+
@HttpCode(HttpStatus.OK)
104+
async revokeUserSessions(
105+
@Param('userId') userId: string,
106+
@Body() request?: { reason?: string },
107+
) {
108+
const result = await this.sessionService.revokeUserSessions(
109+
userId,
110+
request?.reason,
111+
);
112+
return {
113+
success: true,
114+
revokedCount: result.count,
115+
};
116+
}
117+
118+
/**
119+
* Get active sessions for a user
120+
*/
121+
@Get('sessions/:userId')
122+
async getActiveSessions(@Param('userId') userId: string) {
123+
const sessions = await this.sessionService.getActiveSessions(userId);
124+
return { sessions };
77125
}
78126
}

src/auth/auth.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AuthOrchestrator } from './auth-orchestrator.service';
33
import { AuthOrchestratorController } from './auth-orchestrator.controller';
44
import { AuthRateLimitService } from './auth-rate-limit.service';
55
import { AuthRateLimitGuard } from './auth-rate-limit.guard';
6+
import { SessionService } from './session.service';
67
import { IdempotentUserModule } from '../users/idempotent-user.module';
78
import { WalletsModule } from '../wallets/wallets.module';
89
import { IdempotencyService } from '../common/idempotency/idempotency.service';
@@ -12,12 +13,14 @@ import { IdempotencyService } from '../common/idempotency/idempotency.service';
1213
controllers: [AuthOrchestratorController],
1314
providers: [
1415
AuthOrchestrator,
16+
SessionService,
1517
IdempotencyService,
1618
AuthRateLimitService,
1719
AuthRateLimitGuard,
1820
],
1921
exports: [
2022
AuthOrchestrator,
23+
SessionService,
2124
IdempotencyService,
2225
AuthRateLimitService,
2326
AuthRateLimitGuard,

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)