Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions BackendAcademy/src/auth/auth-session.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,10 @@ import { AuthTokensResponse, Session } from './interfaces/session.interface';
import { UserRole } from './enums/user-role.enum';
import { AntiCheatService } from '../security/anti-cheat.service';

/**
* AuthSessionController — Issue #220, #410
*
* Exposes session-management and API key workflow endpoints:
*
* POST /auth/session/login — issue access + refresh token pair
* POST /auth/session/refresh — rotate refresh token, return new pair
* POST /auth/session/logout — revoke single session
* POST /auth/session/logout-all — revoke all sessions for a user
* GET /auth/session/:userId — list active sessions
*
* POST /auth/api-keys — issue a new API key
* GET /auth/api-keys/:userId — list a user's API keys
* POST /auth/api-keys/:keyId/revoke — revoke an API key
* POST /auth/api-keys/:keyId/rotate — rotate an API key
*/
export class HeartbeatDto {
sessionId: string;
}

@Controller('auth/session')
export class AuthSessionController {
constructor(
Expand All @@ -54,16 +42,23 @@ export class AuthSessionController {
return this.authSessionService.refreshTokens(dto.refreshToken);
}

@Post('heartbeat')
@HttpCode(HttpStatus.OK)
async heartbeat(@Body() dto: HeartbeatDto): Promise<{ updated: boolean }> {
await this.authSessionService.updateLastActivity(dto.sessionId);
return { updated: true };
}

@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
logout(@Query('sessionId') sessionId: string): void {
this.authSessionService.revokeSession(sessionId);
async logout(@Query('sessionId') sessionId: string): Promise<void> {
await this.authSessionService.revokeSession(sessionId);
}

@Post('logout-all')
@HttpCode(HttpStatus.NO_CONTENT)
logoutAll(@Query('userId') userId: string): void {
this.authSessionService.revokeAllUserSessions(userId);
async logoutAll(@Query('userId') userId: string): Promise<void> {
await this.authSessionService.revokeAllUserSessions(userId);
}

@Get(':userId')
Expand Down
62 changes: 60 additions & 2 deletions BackendAcademy/src/auth/auth-session.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,18 @@ describe('AuthSessionService security revocation', () => {
});

it('revokes every session after refresh-token reuse', async () => {
const now = Date.now();
const sessionFactory = (sessionId: string): Session => ({
sessionId,
userId: 'user-1',
role: UserRole.LEARNER,
refreshTokenHash: 'hash-of-different-token',
createdAt: new Date(),
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(now),
expiresAt: new Date(now + 60_000),
absoluteExpiresAt: new Date(now + 60_000 + 300_000),
idleExpiresAt: new Date(now + 86_400_000),
deliveryGraceSeconds: 300,
lastActivityAt: new Date(now),
revoked: false,
});
const redis = (service as unknown as { redis: RedisService }).redis;
Expand All @@ -68,4 +73,57 @@ describe('AuthSessionService security revocation', () => {
expect(await service.getActiveSessions('user-1')).toHaveLength(0);
expect(revokeAllUserSessions).toHaveBeenCalledWith('user-1', 'token_reuse');
});

it('updates lastActivityAt on valid activity', async () => {
const now = Date.now();
const session: Session = {
sessionId: 'session-1',
userId: 'user-1',
role: UserRole.LEARNER,
refreshTokenHash: 'hash',
createdAt: new Date(now),
expiresAt: new Date(now + 60_000),
absoluteExpiresAt: new Date(now + 300_000),
idleExpiresAt: new Date(now + 86_400_000),
deliveryGraceSeconds: 300,
lastActivityAt: new Date(now),
revoked: false,
};
const redis = (service as unknown as { redis: RedisService }).redis;
await redis.set('session:session-1', JSON.stringify(session));
await redis.sadd('userSessions:user-1', 'session-1');

await service.validateSession('session-1');

const stored = JSON.parse(await redis.get('session:session-1') as string) as Session;
expect(stored.revoked).toBe(false);
expect(new Date(stored.lastActivityAt).getTime()).toBeGreaterThanOrEqual(now);
});

it('revokes idle sessions on validation', async () => {
const now = Date.now();
const session: Session = {
sessionId: 'session-1',
userId: 'user-1',
role: UserRole.LEARNER,
refreshTokenHash: 'hash',
createdAt: new Date(now - 100_000),
expiresAt: new Date(now + 60_000),
absoluteExpiresAt: new Date(now + 300_000),
idleExpiresAt: new Date(now - 10_000),
deliveryGraceSeconds: 300,
lastActivityAt: new Date(now - 90_000_000),
revoked: false,
};
const redis = (service as unknown as { redis: RedisService }).redis;
await redis.set('session:session-1', JSON.stringify(session));
await redis.sadd('userSessions:user-1', 'session-1');

await expect(service.validateSession('session-1')).rejects.toBeInstanceOf(
UnauthorizedException,
);

const stored = JSON.parse(await redis.get('session:session-1') as string) as Session;
expect(stored.revoked).toBe(true);
});
});
Loading
Loading