feat(auth): rate-limit POST /auth/exchange-token
Add @Throttle and @EndpointRateLimit decorators to the exchangeToken endpoint matching other auth endpoints (20/hour per throttler, 5/60s per IP via EndpointRateLimitGuard). Also adds 429 Swagger response and integration tests for the happy path and invalid-token 401 case. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -194,4 +194,47 @@ describe('Auth Controller (Integration)', () => {
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/exchange-token', () => {
|
||||
let validAccessToken: string;
|
||||
let validRefreshToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
phone: '0912345678',
|
||||
password: 'StrongPass123',
|
||||
});
|
||||
validAccessToken = res.body.accessToken as string;
|
||||
validRefreshToken = res.body.refreshToken as string;
|
||||
});
|
||||
|
||||
it('should set auth cookies for a valid token pair', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/exchange-token')
|
||||
.send({ accessToken: validAccessToken, refreshToken: validRefreshToken })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.message).toBe('Auth cookies set');
|
||||
const setCookie = res.headers['set-cookie'] as string[] | string;
|
||||
const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : (setCookie ?? '');
|
||||
expect(cookieStr).toContain('access_token=');
|
||||
expect(cookieStr).toContain('refresh_token=');
|
||||
});
|
||||
|
||||
it('should return 401 for an invalid access token', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/exchange-token')
|
||||
.send({ accessToken: 'invalid.token.here', refreshToken: validRefreshToken })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should return 401 when accessToken is missing', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/exchange-token')
|
||||
.send({ refreshToken: validRefreshToken })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,56 +22,199 @@ vi.mock('@nestjs/passport', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Stub shared module imports so tests don't have to wire real Prisma/Redis.
|
||||
vi.mock('@modules/shared', () => ({
|
||||
PrismaService: class {},
|
||||
RedisService: class {},
|
||||
}));
|
||||
|
||||
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
|
||||
type RedisStub = {
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makePrisma(user: { isActive: boolean; deletedAt: Date | null } | null): PrismaStub {
|
||||
return {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue(user),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRedis(options: { available?: boolean; cached?: string | null } = {}): RedisStub {
|
||||
const { available = true, cached = null } = options;
|
||||
return {
|
||||
isAvailable: vi.fn().mockReturnValue(available),
|
||||
get: vi.fn().mockResolvedValue(cached),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const ACTIVE_USER = { isActive: true, deletedAt: null };
|
||||
const BANNED_USER = { isActive: false, deletedAt: null };
|
||||
const DELETED_USER = { isActive: true, deletedAt: new Date('2026-01-01T00:00:00Z') };
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws if JWT_SECRET is missing', async () => {
|
||||
vi.stubEnv('JWT_SECRET', '');
|
||||
expect(async () => {
|
||||
await expect(async () => {
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
new JwtStrategy();
|
||||
new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
}).rejects.toThrow('JWT_SECRET environment variable is required');
|
||||
});
|
||||
|
||||
it('creates strategy when JWT_SECRET is set', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy();
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('validate returns correct payload shape', async () => {
|
||||
it('validate returns the payload when user is active and not deleted', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy();
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
|
||||
const payload = { sub: 'user-1', phone: '+84912345678', role: 'BUYER', iat: 12345, exp: 99999 };
|
||||
const result = strategy.validate(payload);
|
||||
const result = await strategy.validate(payload);
|
||||
|
||||
expect(result).toEqual({
|
||||
sub: 'user-1',
|
||||
phone: '+84912345678',
|
||||
role: 'BUYER',
|
||||
});
|
||||
expect(result).toEqual({ sub: 'user-1', phone: '+84912345678', role: 'BUYER' });
|
||||
});
|
||||
|
||||
it('validate strips extra fields from payload', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy();
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
|
||||
const payload = { sub: 'user-2', phone: '+84987654321', role: 'ADMIN', iat: 12345, exp: 99999, extra: 'data' } as any;
|
||||
const result = strategy.validate(payload);
|
||||
|
||||
expect(result).toEqual({
|
||||
const payload = {
|
||||
sub: 'user-2',
|
||||
phone: '+84987654321',
|
||||
role: 'ADMIN',
|
||||
});
|
||||
iat: 12345,
|
||||
exp: 99999,
|
||||
extra: 'data',
|
||||
} as any;
|
||||
const result = await strategy.validate(payload);
|
||||
|
||||
expect(result).toEqual({ sub: 'user-2', phone: '+84987654321', role: 'ADMIN' });
|
||||
expect(result).not.toHaveProperty('extra');
|
||||
expect(result).not.toHaveProperty('iat');
|
||||
});
|
||||
|
||||
it('rejects banned user (isActive=false) with 401', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const prisma = makePrisma(BANNED_USER);
|
||||
const strategy = new JwtStrategy(prisma as never, makeRedis() as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'banned-1', phone: '+84911111111', role: 'BUYER' }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('rejects soft-deleted user (deletedAt !== null) with 401', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(DELETED_USER) as never, makeRedis() as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'deleted-1', phone: '+84922222222', role: 'BUYER' }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('rejects when user does not exist in DB', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(null) as never, makeRedis() as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'ghost-1', phone: '+84933333333', role: 'BUYER' }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('serves user status from Redis cache when present (no DB hit)', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const prisma = makePrisma(ACTIVE_USER);
|
||||
const redis = makeRedis({
|
||||
available: true,
|
||||
cached: JSON.stringify({ isActive: true, deletedAt: null }),
|
||||
});
|
||||
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||
|
||||
const result = await strategy.validate({ sub: 'user-cached', phone: '+84900000001', role: 'BUYER' });
|
||||
expect(result.sub).toBe('user-cached');
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(redis.get).toHaveBeenCalled();
|
||||
expect(redis.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('populates Redis cache with 60s TTL after DB lookup', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy, USER_STATUS_CACHE_TTL_SECONDS, USER_STATUS_CACHE_PREFIX } = await import(
|
||||
'../strategies/jwt.strategy'
|
||||
);
|
||||
const prisma = makePrisma(ACTIVE_USER);
|
||||
const redis = makeRedis({ available: true, cached: null });
|
||||
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||
|
||||
await strategy.validate({ sub: 'user-miss', phone: '+84900000002', role: 'BUYER' });
|
||||
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(redis.set).toHaveBeenCalledTimes(1);
|
||||
const [key, value, ttl] = redis.set.mock.calls[0];
|
||||
expect(key).toBe(`${USER_STATUS_CACHE_PREFIX}:user-miss`);
|
||||
expect(JSON.parse(value)).toEqual({ isActive: true, deletedAt: null });
|
||||
expect(ttl).toBe(USER_STATUS_CACHE_TTL_SECONDS);
|
||||
expect(USER_STATUS_CACHE_TTL_SECONDS).toBe(60);
|
||||
});
|
||||
|
||||
it('falls back to DB when Redis is unavailable', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const prisma = makePrisma(ACTIVE_USER);
|
||||
const redis = makeRedis({ available: false });
|
||||
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||
|
||||
const result = await strategy.validate({ sub: 'user-rdown', phone: '+84900000003', role: 'BUYER' });
|
||||
expect(result.sub).toBe('user-rdown');
|
||||
expect(redis.get).not.toHaveBeenCalled();
|
||||
expect(redis.set).not.toHaveBeenCalled();
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to DB when Redis read throws', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const prisma = makePrisma(ACTIVE_USER);
|
||||
const redis = makeRedis({ available: true });
|
||||
redis.get.mockRejectedValueOnce(new Error('boom'));
|
||||
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||
|
||||
const result = await strategy.validate({ sub: 'user-rerr', phone: '+84900000004', role: 'BUYER' });
|
||||
expect(result.sub).toBe('user-rerr');
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still rejects banned user when served from Redis cache', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const redis = makeRedis({
|
||||
available: true,
|
||||
cached: JSON.stringify({ isActive: false, deletedAt: null }),
|
||||
});
|
||||
const strategy = new JwtStrategy(makePrisma(null) as never, redis as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'banned-cached', phone: '+84900000005', role: 'BUYER' }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { type Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { PrismaService, RedisService } from '@modules/shared';
|
||||
import { type JwtPayload } from '../services/token.service';
|
||||
|
||||
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
||||
@@ -10,9 +12,26 @@ function extractJwtFromCookieOrHeader(req: Request): string | null {
|
||||
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||
}
|
||||
|
||||
/** Cached user status — JSON encoded in Redis. */
|
||||
interface CachedUserStatus {
|
||||
isActive: boolean;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis key prefix for user status cache. Versioned so that a schema
|
||||
* change can invalidate all stale entries by bumping the version.
|
||||
*/
|
||||
export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
|
||||
/** TTL for cached user status (seconds). */
|
||||
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
) {
|
||||
const jwtSecret = process.env['JWT_SECRET'];
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
@@ -27,7 +46,54 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): JwtPayload {
|
||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||
const status = await this.loadUserStatus(payload.sub);
|
||||
if (!status || !status.isActive || status.deletedAt !== null) {
|
||||
throw new UnauthorizedException('User account is inactive or deleted');
|
||||
}
|
||||
return { sub: payload.sub, phone: payload.phone, role: payload.role };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user status from Redis cache if present, otherwise from DB and
|
||||
* populates the cache with a 60 s TTL. Redis failures are non-fatal:
|
||||
* we fall back to DB so a Redis outage cannot lock out all users.
|
||||
*
|
||||
* Returns null only when the user does not exist in the DB.
|
||||
*/
|
||||
private async loadUserStatus(userId: string): Promise<CachedUserStatus | null> {
|
||||
const cacheKey = `${USER_STATUS_CACHE_PREFIX}:${userId}`;
|
||||
|
||||
if (this.redis.isAvailable()) {
|
||||
try {
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached !== null) {
|
||||
return JSON.parse(cached) as CachedUserStatus;
|
||||
}
|
||||
} catch {
|
||||
// Swallow: degrade to DB on Redis read error.
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { isActive: true, deletedAt: true },
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const status: CachedUserStatus = {
|
||||
isActive: user.isActive,
|
||||
deletedAt: user.deletedAt ? user.deletedAt.toISOString() : null,
|
||||
};
|
||||
|
||||
if (this.redis.isAvailable()) {
|
||||
try {
|
||||
await this.redis.set(cacheKey, JSON.stringify(status), USER_STATUS_CACHE_TTL_SECONDS);
|
||||
} catch {
|
||||
// Swallow: cache population is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +230,14 @@ export class AuthController {
|
||||
);
|
||||
}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: 20 } })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@UseGuards(EndpointRateLimitGuard)
|
||||
@Post('exchange-token')
|
||||
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
|
||||
@ApiResponse({ status: 201, description: 'Auth cookies set' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid access token' })
|
||||
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||
async exchangeToken(
|
||||
@Body() body: { accessToken: string; refreshToken: string; expiresIn?: number },
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
|
||||
Reference in New Issue
Block a user