Compare commits
2 Commits
732e9b02bd
...
b60b327508
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60b327508 | ||
|
|
25edb3579c |
@@ -50,6 +50,7 @@
|
||||
"handlebars": "^4.7.9",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^13.4.0",
|
||||
"passport": "^0.7.0",
|
||||
@@ -76,6 +77,7 @@
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
|
||||
@@ -5,16 +5,12 @@ import { PrismaAgentRepository } from '../repositories/prisma-agent.repository';
|
||||
describe('PrismaAgentRepository', () => {
|
||||
let repository: PrismaAgentRepository;
|
||||
let mockPrisma: {
|
||||
$queryRaw: ReturnType<typeof vi.fn>;
|
||||
agent: {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
lead: {
|
||||
groupBy: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
inquiry: {
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
listing: {
|
||||
@@ -43,16 +39,12 @@ describe('PrismaAgentRepository', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
$queryRaw: vi.fn(),
|
||||
agent: {
|
||||
findUnique: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
lead: {
|
||||
groupBy: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
inquiry: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
listing: {
|
||||
@@ -198,32 +190,31 @@ describe('PrismaAgentRepository', () => {
|
||||
});
|
||||
|
||||
describe('getDashboard', () => {
|
||||
it('returns full dashboard data', async () => {
|
||||
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'agent-1',
|
||||
qualityScore: 85,
|
||||
totalDeals: 12,
|
||||
responseTimeAvg: 600,
|
||||
isVerified: true,
|
||||
});
|
||||
mockPrisma.lead.groupBy.mockResolvedValue([
|
||||
{ status: 'NEW', _count: { id: 5 } },
|
||||
{ status: 'CONTACTED', _count: { id: 10 } },
|
||||
{ status: 'CONVERTED', _count: { id: 3 } },
|
||||
]);
|
||||
mockPrisma.inquiry.count
|
||||
.mockResolvedValueOnce(45) // total
|
||||
.mockResolvedValueOnce(3); // unread
|
||||
mockPrisma.listing.count
|
||||
.mockResolvedValueOnce(15) // total
|
||||
.mockResolvedValueOnce(10); // active
|
||||
mockPrisma.review.aggregate.mockResolvedValue({
|
||||
_avg: { rating: 4.5 },
|
||||
_count: { rating: 20 },
|
||||
});
|
||||
const mockStatsRow = {
|
||||
agentId: 'agent-1',
|
||||
qualityScore: 85,
|
||||
totalDeals: 12,
|
||||
responseTimeAvg: 600,
|
||||
isVerified: true,
|
||||
leadsByStatus: { NEW: 5, CONTACTED: 10, CONVERTED: 3 },
|
||||
totalLeads: 18,
|
||||
convertedLeads: 3,
|
||||
totalListings: 15,
|
||||
activeListings: 10,
|
||||
totalInquiries: 45,
|
||||
unreadInquiries: 3,
|
||||
avgRating: 4.5,
|
||||
totalReviews: 20,
|
||||
};
|
||||
|
||||
it('returns full dashboard data using single aggregate query', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([mockStatsRow]);
|
||||
|
||||
const result = await repository.getDashboard('agent-1');
|
||||
|
||||
// Verify single DB call
|
||||
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result.agentId).toBe('agent-1');
|
||||
expect(result.qualityScore).toBe(85);
|
||||
expect(result.totalDeals).toBe(12);
|
||||
@@ -240,21 +231,23 @@ describe('PrismaAgentRepository', () => {
|
||||
expect(result.totalReviews).toBe(20);
|
||||
});
|
||||
|
||||
it('handles agent with zero leads', async () => {
|
||||
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'agent-1',
|
||||
it('handles agent with zero leads and reviews', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([{
|
||||
...mockStatsRow,
|
||||
qualityScore: 0,
|
||||
totalDeals: 0,
|
||||
responseTimeAvg: null,
|
||||
isVerified: false,
|
||||
});
|
||||
mockPrisma.lead.groupBy.mockResolvedValue([]);
|
||||
mockPrisma.inquiry.count.mockResolvedValue(0);
|
||||
mockPrisma.listing.count.mockResolvedValue(0);
|
||||
mockPrisma.review.aggregate.mockResolvedValue({
|
||||
_avg: { rating: null },
|
||||
_count: { rating: 0 },
|
||||
});
|
||||
leadsByStatus: {},
|
||||
totalLeads: 0,
|
||||
convertedLeads: 0,
|
||||
totalListings: 0,
|
||||
activeListings: 0,
|
||||
totalInquiries: 0,
|
||||
unreadInquiries: 0,
|
||||
avgRating: 0,
|
||||
totalReviews: 0,
|
||||
}]);
|
||||
|
||||
const result = await repository.getDashboard('agent-1');
|
||||
|
||||
@@ -264,6 +257,14 @@ describe('PrismaAgentRepository', () => {
|
||||
expect(result.avgReviewRating).toBe(0);
|
||||
expect(result.totalReviews).toBe(0);
|
||||
});
|
||||
|
||||
it('throws when agent not found (empty result set)', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
|
||||
await expect(repository.getDashboard('nonexistent')).rejects.toThrow(
|
||||
'Agent not found: nonexistent',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicProfile', () => {
|
||||
|
||||
@@ -10,6 +10,24 @@ import {
|
||||
import { QualityScore } from '../../domain/value-objects/quality-score.vo';
|
||||
import { buildPublicProfile } from './agent-profile.queries';
|
||||
|
||||
/** Shape returned by the single-aggregate getDashboard SQL query. */
|
||||
interface AgentStatsRow {
|
||||
agentId: string;
|
||||
qualityScore: number;
|
||||
totalDeals: number;
|
||||
responseTimeAvg: number | null;
|
||||
isVerified: boolean;
|
||||
leadsByStatus: unknown;
|
||||
totalLeads: number;
|
||||
convertedLeads: number;
|
||||
totalListings: number;
|
||||
activeListings: number;
|
||||
totalInquiries: number;
|
||||
unreadInquiries: number;
|
||||
avgRating: number;
|
||||
totalReviews: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaAgentRepository implements IAgentRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -34,56 +52,104 @@ export class PrismaAgentRepository implements IAgentRepository {
|
||||
}
|
||||
|
||||
async getDashboard(agentId: string): Promise<AgentDashboardData> {
|
||||
const [agent, leads, inquiryStats, listingStats, reviewStats] =
|
||||
await Promise.all([
|
||||
this.prisma.agent.findUniqueOrThrow({
|
||||
where: { id: agentId },
|
||||
select: {
|
||||
id: true, qualityScore: true, totalDeals: true,
|
||||
responseTimeAvg: true, isVerified: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.lead.groupBy({
|
||||
by: ['status'],
|
||||
where: { agentId },
|
||||
_count: { id: true },
|
||||
}),
|
||||
this.getInquiryStats(agentId),
|
||||
this.getListingStats(agentId),
|
||||
this.prisma.review.aggregate({
|
||||
where: { targetType: 'AGENT', targetId: agentId },
|
||||
_avg: { rating: true },
|
||||
_count: { rating: true },
|
||||
}),
|
||||
]);
|
||||
// Single aggregate query — replaces 7 separate round-trips.
|
||||
const rows = await this.prisma.$queryRaw<AgentStatsRow[]>`
|
||||
WITH
|
||||
agent_base AS (
|
||||
SELECT
|
||||
id,
|
||||
"qualityScore",
|
||||
"totalDeals",
|
||||
"responseTimeAvg",
|
||||
"isVerified"
|
||||
FROM "Agent"
|
||||
WHERE id = ${agentId}
|
||||
),
|
||||
lead_stats AS (
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*)::int AS cnt
|
||||
FROM "Lead"
|
||||
WHERE "agentId" = ${agentId}
|
||||
GROUP BY status
|
||||
),
|
||||
listing_agg AS (
|
||||
SELECT
|
||||
COUNT(*)::int AS total_listings,
|
||||
COUNT(*) FILTER (WHERE status = 'ACTIVE')::int AS active_listings
|
||||
FROM "Listing"
|
||||
WHERE "agentId" = ${agentId}
|
||||
),
|
||||
inquiry_agg AS (
|
||||
SELECT
|
||||
COUNT(*)::int AS total_inquiries,
|
||||
COUNT(*) FILTER (WHERE i."isRead" = false)::int AS unread_inquiries
|
||||
FROM "Inquiry" i
|
||||
JOIN "Listing" l ON l.id = i."listingId"
|
||||
WHERE l."agentId" = ${agentId}
|
||||
),
|
||||
review_agg AS (
|
||||
SELECT
|
||||
COALESCE(AVG(rating), 0)::float AS avg_rating,
|
||||
COUNT(*)::int AS total_reviews
|
||||
FROM "Review"
|
||||
WHERE "targetType" = 'AGENT'
|
||||
AND "targetId" = ${agentId}
|
||||
)
|
||||
SELECT
|
||||
a.id AS "agentId",
|
||||
a."qualityScore",
|
||||
a."totalDeals",
|
||||
a."responseTimeAvg",
|
||||
a."isVerified",
|
||||
COALESCE(
|
||||
(SELECT jsonb_object_agg(status, cnt) FROM lead_stats),
|
||||
'{}'::jsonb
|
||||
) AS "leadsByStatus",
|
||||
COALESCE(
|
||||
(SELECT SUM(cnt)::int FROM lead_stats),
|
||||
0
|
||||
) AS "totalLeads",
|
||||
COALESCE(
|
||||
(SELECT cnt FROM lead_stats WHERE status = 'CONVERTED'),
|
||||
0
|
||||
) AS "convertedLeads",
|
||||
la.total_listings AS "totalListings",
|
||||
la.active_listings AS "activeListings",
|
||||
ia.total_inquiries AS "totalInquiries",
|
||||
ia.unread_inquiries AS "unreadInquiries",
|
||||
ra.avg_rating AS "avgRating",
|
||||
ra.total_reviews AS "totalReviews"
|
||||
FROM agent_base a
|
||||
CROSS JOIN listing_agg la
|
||||
CROSS JOIN inquiry_agg ia
|
||||
CROSS JOIN review_agg ra
|
||||
`;
|
||||
|
||||
const leadsByStatus: Record<string, number> = {};
|
||||
let totalLeads = 0;
|
||||
let convertedLeads = 0;
|
||||
|
||||
for (const group of leads) {
|
||||
leadsByStatus[group.status] = group._count.id;
|
||||
totalLeads += group._count.id;
|
||||
if (group.status === 'CONVERTED') convertedLeads = group._count.id;
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const row = rows[0]!;
|
||||
const totalLeads = row.totalLeads ?? 0;
|
||||
const convertedLeads = row.convertedLeads ?? 0;
|
||||
const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0;
|
||||
|
||||
return {
|
||||
agentId: agent.id,
|
||||
qualityScore: agent.qualityScore,
|
||||
totalDeals: agent.totalDeals,
|
||||
responseTimeAvg: agent.responseTimeAvg,
|
||||
isVerified: agent.isVerified,
|
||||
agentId: row.agentId,
|
||||
qualityScore: row.qualityScore,
|
||||
totalDeals: row.totalDeals,
|
||||
responseTimeAvg: row.responseTimeAvg,
|
||||
isVerified: row.isVerified,
|
||||
totalLeads,
|
||||
leadsByStatus,
|
||||
leadsByStatus: (row.leadsByStatus as Record<string, number>) ?? {},
|
||||
conversionRate: Math.round(conversionRate * 1000) / 1000,
|
||||
totalInquiries: inquiryStats.total,
|
||||
unreadInquiries: inquiryStats.unread,
|
||||
totalListings: listingStats.total,
|
||||
activeListings: listingStats.active,
|
||||
avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10,
|
||||
totalReviews: reviewStats._count.rating,
|
||||
totalInquiries: row.totalInquiries ?? 0,
|
||||
unreadInquiries: row.unreadInquiries ?? 0,
|
||||
totalListings: row.totalListings ?? 0,
|
||||
activeListings: row.activeListings ?? 0,
|
||||
avgReviewRating: Math.round((row.avgRating ?? 0) * 10) / 10,
|
||||
totalReviews: row.totalReviews ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,26 +191,6 @@ export class PrismaAgentRepository implements IAgentRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private async getInquiryStats(
|
||||
agentId: string,
|
||||
): Promise<{ total: number; unread: number }> {
|
||||
const [total, unread] = await Promise.all([
|
||||
this.prisma.inquiry.count({ where: { listing: { agentId } } }),
|
||||
this.prisma.inquiry.count({ where: { listing: { agentId }, isRead: false } }),
|
||||
]);
|
||||
return { total, unread };
|
||||
}
|
||||
|
||||
private async getListingStats(
|
||||
agentId: string,
|
||||
): Promise<{ total: number; active: number }> {
|
||||
const [total, active] = await Promise.all([
|
||||
this.prisma.listing.count({ where: { agentId } }),
|
||||
this.prisma.listing.count({ where: { agentId, status: 'ACTIVE' } }),
|
||||
]);
|
||||
return { total, active };
|
||||
}
|
||||
|
||||
private toDomain(row: {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
||||
@@ -28,6 +28,11 @@ vi.mock('@modules/shared', () => ({
|
||||
RedisService: class {},
|
||||
}));
|
||||
|
||||
// Mock jsonwebtoken so we can control which secret succeeds
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
verify: vi.fn(),
|
||||
}));
|
||||
|
||||
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
|
||||
type RedisStub = {
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
@@ -218,3 +223,118 @@ describe('JwtStrategy', () => {
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('JwtStrategy dual-key (secretOrKeyProvider)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses secretOrKeyProvider instead of secretOrKey', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
|
||||
const options = (strategy as any)._options;
|
||||
expect(options.secretOrKeyProvider).toBeDefined();
|
||||
expect(options.secretOrKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('secretOrKeyProvider returns primary secret when primary verification succeeds', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||
delete process.env['JWT_SECRET_PREVIOUS'];
|
||||
|
||||
const { verify } = await import('jsonwebtoken');
|
||||
const mockVerify = vi.mocked(verify);
|
||||
mockVerify.mockReturnValueOnce({} as any);
|
||||
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
|
||||
const options = (strategy as any)._options;
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
options.secretOrKeyProvider({} as any, 'some-jwt-token', (err: Error | null, secret?: string) => {
|
||||
if (err) reject(err);
|
||||
else resolve(secret!);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toBe('primary-secret-at-least-32-chars');
|
||||
});
|
||||
|
||||
it('secretOrKeyProvider falls back to previous secret when primary fails', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||
vi.stubEnv('JWT_SECRET_PREVIOUS', 'previous-secret-at-least-32-chars');
|
||||
|
||||
const { verify } = await import('jsonwebtoken');
|
||||
const mockVerify = vi.mocked(verify);
|
||||
mockVerify.mockImplementationOnce(() => {
|
||||
throw new Error('invalid signature');
|
||||
});
|
||||
mockVerify.mockReturnValueOnce({} as any);
|
||||
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
|
||||
const options = (strategy as any)._options;
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
options.secretOrKeyProvider({} as any, 'old-jwt-token', (err: Error | null, secret?: string) => {
|
||||
if (err) reject(err);
|
||||
else resolve(secret!);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toBe('previous-secret-at-least-32-chars');
|
||||
});
|
||||
|
||||
it('secretOrKeyProvider returns primary when both keys fail (passport will 401)', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||
vi.stubEnv('JWT_SECRET_PREVIOUS', 'previous-secret-at-least-32-chars');
|
||||
|
||||
const { verify } = await import('jsonwebtoken');
|
||||
const mockVerify = vi.mocked(verify);
|
||||
mockVerify.mockImplementation(() => {
|
||||
throw new Error('invalid signature');
|
||||
});
|
||||
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
|
||||
const options = (strategy as any)._options;
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
options.secretOrKeyProvider({} as any, 'bad-jwt-token', (err: Error | null, secret?: string) => {
|
||||
if (err) reject(err);
|
||||
else resolve(secret!);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toBe('primary-secret-at-least-32-chars');
|
||||
});
|
||||
|
||||
it('secretOrKeyProvider skips fallback when no previous secret is configured', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||
delete process.env['JWT_SECRET_PREVIOUS'];
|
||||
|
||||
const { verify } = await import('jsonwebtoken');
|
||||
const mockVerify = vi.mocked(verify);
|
||||
mockVerify.mockImplementation(() => {
|
||||
throw new Error('invalid signature');
|
||||
});
|
||||
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
|
||||
const options = (strategy as any)._options;
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
options.secretOrKeyProvider({} as any, 'bad-jwt-token', (err: Error | null, secret?: string) => {
|
||||
if (err) reject(err);
|
||||
else resolve(secret!);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toBe('primary-secret-at-least-32-chars');
|
||||
// verify called only once — no fallback attempted
|
||||
expect(mockVerify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { type Request } from 'express';
|
||||
import { verify as jwtVerify } from 'jsonwebtoken';
|
||||
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';
|
||||
@@ -26,6 +27,42 @@ export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
|
||||
/** TTL for cached user status (seconds). */
|
||||
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Builds a `secretOrKeyProvider` callback for passport-jwt that tries the
|
||||
* primary secret first, then falls back to an optional previous secret.
|
||||
* This enables zero-downtime JWT secret rotation: tokens signed with the
|
||||
* old key remain valid during the grace period.
|
||||
*
|
||||
* When only the primary secret is configured (no `_PREVIOUS` env var),
|
||||
* the behaviour is identical to the original `secretOrKey` approach.
|
||||
*/
|
||||
export function makeSecretOrKeyProvider(
|
||||
primarySecret: string,
|
||||
previousSecret: string | undefined,
|
||||
): (request: Request, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => void {
|
||||
return (_request: Request, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => {
|
||||
// Fast path: try primary first (the common case after rotation completes).
|
||||
try {
|
||||
jwtVerify(rawJwtToken, primarySecret, { audience: 'goodgo-api', issuer: 'goodgo-platform' });
|
||||
return done(null, primarySecret);
|
||||
} catch {
|
||||
// Primary failed — try previous if configured.
|
||||
}
|
||||
|
||||
if (previousSecret) {
|
||||
try {
|
||||
jwtVerify(rawJwtToken, previousSecret, { audience: 'goodgo-api', issuer: 'goodgo-platform' });
|
||||
return done(null, previousSecret);
|
||||
} catch {
|
||||
// Both keys failed — fall through to let passport return 401.
|
||||
}
|
||||
}
|
||||
|
||||
// Return the primary so passport-jwt produces its standard error.
|
||||
return done(null, primarySecret);
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
@@ -37,10 +74,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
}
|
||||
|
||||
const previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
|
||||
|
||||
super({
|
||||
jwtFromRequest: extractJwtFromCookieOrHeader,
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: jwtSecret,
|
||||
secretOrKeyProvider: makeSecretOrKeyProvider(jwtSecret, previousSecret),
|
||||
audience: 'goodgo-api',
|
||||
issuer: 'goodgo-platform',
|
||||
});
|
||||
|
||||
@@ -145,4 +145,52 @@ describe('validateEnv', () => {
|
||||
|
||||
expect(() => validateEnv()).not.toThrow();
|
||||
});
|
||||
|
||||
describe('optional previous secrets (JWT_SECRET_PREVIOUS / JWT_REFRESH_SECRET_PREVIOUS)', () => {
|
||||
it('accepts when previous secrets are not set (no rotation in progress)', () => {
|
||||
setValidJwtSecrets();
|
||||
delete process.env['JWT_SECRET_PREVIOUS'];
|
||||
delete process.env['JWT_REFRESH_SECRET_PREVIOUS'];
|
||||
|
||||
expect(() => validateEnv()).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts valid JWT_SECRET_PREVIOUS', () => {
|
||||
setValidJwtSecrets();
|
||||
process.env['JWT_SECRET_PREVIOUS'] = 'a-valid-previous-secret-with-at-least-32-characters';
|
||||
|
||||
expect(() => validateEnv()).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects JWT_SECRET_PREVIOUS when it is a placeholder', () => {
|
||||
setValidJwtSecrets();
|
||||
process.env['JWT_SECRET_PREVIOUS'] = 'CHANGE_ME';
|
||||
|
||||
expect(() => validateEnv()).toThrow('Insecure JWT secret configuration');
|
||||
expect(() => validateEnv()).toThrow('JWT_SECRET_PREVIOUS');
|
||||
});
|
||||
|
||||
it('rejects JWT_SECRET_PREVIOUS when it is too short', () => {
|
||||
setValidJwtSecrets();
|
||||
process.env['JWT_SECRET_PREVIOUS'] = 'short';
|
||||
|
||||
expect(() => validateEnv()).toThrow('too short');
|
||||
});
|
||||
|
||||
it('rejects JWT_REFRESH_SECRET_PREVIOUS when it is a placeholder', () => {
|
||||
setValidJwtSecrets();
|
||||
process.env['JWT_REFRESH_SECRET_PREVIOUS'] = 'password';
|
||||
|
||||
expect(() => validateEnv()).toThrow('Insecure JWT secret configuration');
|
||||
expect(() => validateEnv()).toThrow('JWT_REFRESH_SECRET_PREVIOUS');
|
||||
});
|
||||
|
||||
it('accepts both previous secrets when valid', () => {
|
||||
setValidJwtSecrets();
|
||||
process.env['JWT_SECRET_PREVIOUS'] = 'previous-access-secret-at-least-32-chars-long!!';
|
||||
process.env['JWT_REFRESH_SECRET_PREVIOUS'] = 'previous-refresh-secret-at-least-32-chars-long!!';
|
||||
|
||||
expect(() => validateEnv()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,18 @@ const ALWAYS_REQUIRED: readonly string[] = [
|
||||
'JWT_REFRESH_SECRET',
|
||||
];
|
||||
|
||||
/**
|
||||
* Optional previous-generation JWT secrets used during key rotation.
|
||||
* When set, the auth layer will accept tokens signed with either the
|
||||
* primary or the previous key, enabling zero-downtime secret rotation.
|
||||
* Subject to the same placeholder-rejection and minimum-length checks
|
||||
* as the primary secrets.
|
||||
*/
|
||||
const OPTIONAL_PREVIOUS_SECRETS: readonly string[] = [
|
||||
'JWT_SECRET_PREVIOUS',
|
||||
'JWT_REFRESH_SECRET_PREVIOUS',
|
||||
];
|
||||
|
||||
const REQUIRED_IN_PRODUCTION: readonly string[] = [
|
||||
'DATABASE_URL',
|
||||
'CORS_ORIGINS',
|
||||
@@ -120,6 +132,19 @@ export function validateEnv(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Optional previous-generation secrets — if set, they must also pass
|
||||
// the same strength checks. An empty/unset value is fine (no rotation
|
||||
// in progress), but a weak value is always rejected.
|
||||
for (const key of OPTIONAL_PREVIOUS_SECRETS) {
|
||||
const value = process.env[key];
|
||||
if (value) {
|
||||
const error = validateJwtSecret(key, value);
|
||||
if (error) {
|
||||
secretErrors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (secretErrors.length > 0) {
|
||||
throw new Error(
|
||||
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\n` +
|
||||
|
||||
192
docs/security/SECRET_ROTATION_POLICY.md
Normal file
192
docs/security/SECRET_ROTATION_POLICY.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Payment Gateway Secret Rotation Policy
|
||||
|
||||
> **Status:** Active — GOO-197 / parent [GOO-102](/GOO/issues/GOO-102) (CLO data-security work).
|
||||
> **Owner:** Security Engineer + Platform on-call.
|
||||
> **Last reviewed:** 2026-04-24.
|
||||
|
||||
This document is the canonical policy for rotating all secrets that gate
|
||||
access to GoodGo's payment gateways and adjacent integrations (OAuth,
|
||||
storage, webhook signing, JWT). It ships alongside the `SecretProvider`
|
||||
abstraction in
|
||||
`apps/api/src/modules/shared/domain/ports/secret-provider.port.ts` and the
|
||||
env-backed implementation in
|
||||
`apps/api/src/modules/shared/infrastructure/env-secret-provider.service.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why rotate
|
||||
|
||||
A stolen or leaked HMAC key for a payment gateway is the most direct path
|
||||
to financial fraud against GoodGo. Rotation reduces the **window of abuse**
|
||||
when a key is exposed (insider misuse, accidental git commit, third-party
|
||||
breach, log scraping, etc.). It also forces us to verify that every
|
||||
runtime that relies on the key can still read it — i.e. that we have not
|
||||
lost the ability to rotate.
|
||||
|
||||
## 2. Scope (rotation-sensitive secrets)
|
||||
|
||||
The following secrets are in scope. Each is registered with the
|
||||
`SecretProvider` by default (see `DEFAULT_REGISTERED_SECRETS`) and has a
|
||||
matching entry in `env-validation.ts`.
|
||||
|
||||
| Secret env var | Purpose | Cadence | Owner |
|
||||
| ------------------------------ | ------------------------------- | -------- | -------------- |
|
||||
| `JWT_SECRET` | Access-token HMAC | 90 days | Auth |
|
||||
| `JWT_REFRESH_SECRET` | Refresh-token HMAC | 90 days | Auth |
|
||||
| `VNPAY_HASH_SECRET` | VNPay request/callback HMAC | 90 days | Payments |
|
||||
| `MOMO_SECRET_KEY` | MoMo request/callback HMAC | 90 days | Payments |
|
||||
| `ZALOPAY_KEY1` | ZaloPay order signing | 90 days | Payments |
|
||||
| `ZALOPAY_KEY2` | ZaloPay callback signing | 90 days | Payments |
|
||||
| `BANK_TRANSFER_WEBHOOK_SECRET` | Bank-transfer webhook signature | 90 days | Payments |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth | 180 days | Auth |
|
||||
| `ZALO_APP_SECRET` | Zalo OAuth | 180 days | Auth |
|
||||
| `ZALO_OA_ACCESS_TOKEN` | Zalo Official Account API token | 90 days | Notifications |
|
||||
| `MINIO_SECRET_KEY` | Object-storage access key | 180 days | Platform |
|
||||
| `FIELD_ENCRYPTION_KEY` | At-rest PII encryption key | annually | Platform + CLO |
|
||||
|
||||
Secrets **not** in this table (e.g. `DATABASE_URL` password, `REDIS_HOST`)
|
||||
follow the platform-credential rotation policy and are out of scope here.
|
||||
|
||||
## 3. Cadence and triggers
|
||||
|
||||
- **Routine rotation:** every 90 days for HMAC/signing keys, 180 days for
|
||||
OAuth client secrets, annually for the field-encryption key (which has
|
||||
expensive data-rewrap implications).
|
||||
- **Event-driven rotation (always immediately):**
|
||||
- any commit accidentally containing a real value of one of the secrets
|
||||
above (regardless of how briefly);
|
||||
- departure of any individual with production access to the secret store;
|
||||
- downstream provider notification that the credential may be exposed;
|
||||
- confirmed or strongly suspected breach of any system that handled the
|
||||
secret in plaintext (CI runner, dev laptop, log aggregator, …).
|
||||
|
||||
## 4. Operator workflow (env-backed backend)
|
||||
|
||||
1. **Generate** a new high-entropy value:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 48
|
||||
```
|
||||
|
||||
2. **Stage the dual-key grace period.** Copy the current secret to the
|
||||
`_PREVIOUS` variable and set the new secret as the primary:
|
||||
|
||||
```bash
|
||||
# Example for JWT_SECRET rotation:
|
||||
JWT_SECRET_PREVIOUS=<current-value-of-JWT_SECRET>
|
||||
JWT_SECRET=<newly-generated-value>
|
||||
# Same pattern for JWT_REFRESH_SECRET if rotating refresh keys.
|
||||
```
|
||||
|
||||
The auth layer automatically tries the primary key first and falls
|
||||
back to `_PREVIOUS`, so tokens signed with the old key continue to
|
||||
validate during the grace period (≤ access-token TTL, typically 15 m).
|
||||
|
||||
3. **Deploy** the change. On boot, every API instance logs:
|
||||
|
||||
```
|
||||
[EnvSecretProvider] Secret versions at boot: VNPAY_HASH_SECRET=2026-04-24, …
|
||||
```
|
||||
|
||||
Verify the version field matches the staged version on every instance.
|
||||
The raw value **must never** appear in this or any other log line.
|
||||
|
||||
4. **Smoke-test** payment flows for the rotated provider:
|
||||
- issue one sandbox payment
|
||||
- confirm callback verification succeeds
|
||||
- confirm refund signing succeeds
|
||||
Record the rotation in the security audit log
|
||||
(`docs/security/secret-rotation-log.md` — append-only).
|
||||
|
||||
5. **Decommission** the old credential in the gateway's merchant portal.
|
||||
|
||||
6. **Remove the previous secret.** After the grace period (at least one
|
||||
full access-token TTL cycle, typically 15 minutes), remove
|
||||
`JWT_SECRET_PREVIOUS` (and/or `JWT_REFRESH_SECRET_PREVIOUS`) from the
|
||||
environment and redeploy. This closes the dual-key window.
|
||||
|
||||
## 5. SecretProvider abstraction (developer workflow)
|
||||
|
||||
All new and existing code that consumes a rotation-sensitive secret MUST
|
||||
go through the `SecretProvider` port:
|
||||
|
||||
```ts
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { SECRET_PROVIDER, type ISecretProvider } from '@modules/shared/domain/ports';
|
||||
|
||||
@Injectable()
|
||||
export class VnpayService {
|
||||
constructor(@Inject(SECRET_PROVIDER) private readonly secrets: ISecretProvider) {}
|
||||
|
||||
async sign(payload: string): Promise<string> {
|
||||
const { value } = await this.secrets.getSecret('VNPAY_HASH_SECRET');
|
||||
// … HMAC with `value`, never store it on `this`, never log it.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Never** capture the raw value into a service field. Always re-read on
|
||||
the request path so a rotation takes effect at the next request.
|
||||
- **Never** include `material.value` in log messages, error messages, or
|
||||
exception payloads. `material.version` is safe to log.
|
||||
- **Never** stringify a `SecretMaterial` directly into a response body.
|
||||
- For bootstrap-only contexts where `await` is awkward, use
|
||||
`getSecretSync` — but note that a future remote backend may throw
|
||||
`UnsupportedSyncReadError`.
|
||||
|
||||
## 6. Backends
|
||||
|
||||
- **Short term — `EnvSecretProvider` (current).** Reads from `process.env`
|
||||
via `ConfigService`. Operationally identical to the pre-existing
|
||||
`getOrThrow('VNPAY_HASH_SECRET')` calls, but with a stable audit surface
|
||||
(versions logged, port-based DI).
|
||||
- **Mid term — `AwsSecretsManagerSecretProvider` / `VaultSecretProvider`.**
|
||||
Same port. Adds:
|
||||
- automatic refresh from the remote store
|
||||
- per-secret IAM / Vault-policy scoping
|
||||
- native version ids (`AWSCURRENT` / `AWSPREVIOUS` etc.) surfaced as
|
||||
`material.version`
|
||||
- `getSecretSync` may throw `UnsupportedSyncReadError`; bootstrap
|
||||
callers must migrate to `getSecret`.
|
||||
|
||||
Switching backends is a one-line change in `SharedModule` (replace
|
||||
`EnvSecretProvider` with the new implementation under the
|
||||
`SECRET_PROVIDER` token). No call sites change.
|
||||
|
||||
## 7. Logging discipline
|
||||
|
||||
- The `EnvSecretProvider` logs only `name=version` pairs at boot.
|
||||
- The `version` is either an operator-provided `<NAME>_SECRET_VERSION` env
|
||||
var, or a 10-char SHA-256 fingerprint of the value (40 bits of entropy;
|
||||
non-invertible; useful for distinguishing rotations across instances).
|
||||
- Negative tests in
|
||||
`apps/api/src/modules/shared/infrastructure/__tests__/env-secret-provider.service.spec.ts`
|
||||
assert the raw value never appears in logger output, error messages, or
|
||||
serialized provider state.
|
||||
- The repo also has a global `pii-masker` and `GlobalExceptionFilter` —
|
||||
those are defence-in-depth, not the primary control. The primary control
|
||||
is "never put the value into a string in the first place."
|
||||
|
||||
## 8. Incident response (suspected leak)
|
||||
|
||||
1. Open a P1 incident in `#sec-incident`. Page Security on-call.
|
||||
2. Rotate the affected secret immediately following §4 — do not wait for
|
||||
forensic confirmation.
|
||||
3. Search logs / CI artifacts / git history for the leaked value
|
||||
fingerprint (NOT the value itself; use `fingerprint()` from
|
||||
`env-secret-provider.service.ts`).
|
||||
4. Coordinate with the gateway's anti-fraud team where applicable (VNPay,
|
||||
MoMo, ZaloPay merchant support).
|
||||
5. File a post-mortem within 5 business days; update this policy if
|
||||
process gaps were found.
|
||||
|
||||
## 9. References
|
||||
|
||||
- Source port: `apps/api/src/modules/shared/domain/ports/secret-provider.port.ts`
|
||||
- Env-backed impl: `apps/api/src/modules/shared/infrastructure/env-secret-provider.service.ts`
|
||||
- Env validation: `apps/api/src/modules/shared/infrastructure/env-validation.ts`
|
||||
- Negative tests: `apps/api/src/modules/shared/infrastructure/__tests__/env-secret-provider.service.spec.ts`
|
||||
- Parent issue: [GOO-102](/GOO/issues/GOO-102)
|
||||
- This issue: [GOO-197](/GOO/issues/GOO-197)
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -186,6 +186,9 @@ importers:
|
||||
ioredis:
|
||||
specifier: ^5.4.0
|
||||
version: 5.10.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
nodemailer:
|
||||
specifier: ^8.0.5
|
||||
version: 8.0.5
|
||||
@@ -259,6 +262,9 @@ importers:
|
||||
'@types/express':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.6
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.10
|
||||
version: 9.0.10
|
||||
'@types/node':
|
||||
specifier: ^25.5.2
|
||||
version: 25.5.2
|
||||
|
||||
Reference in New Issue
Block a user