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
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,36 @@ import { AuthResolver } from '@/interface-adapters/resolvers/AuthResolver.js';
import { makeUser } from '@/__tests__/helpers/mocks.js';
import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js';
import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js';
import type { ITokenService } from '@/use-cases/ports/ITokenService.js';

const makeRegisterUseCase = (overrides?: Partial<IRegisterUseCase>): IRegisterUseCase =>
({ execute: vi.fn(), ...overrides });

const makeLoginUseCase = (overrides?: Partial<ILoginUseCase>): ILoginUseCase =>
({ execute: vi.fn(), ...overrides });

const makeFastify = () => ({
jwt: {
sign: vi.fn().mockReturnValue('signed-token'),
verify: vi.fn(),
},
const makeTokenService = (overrides?: Partial<ITokenService>): ITokenService => ({
sign: vi.fn().mockReturnValue({ accessToken: 'access-token', refreshToken: 'refresh-token' }),
verifyRefresh: vi.fn(),
...overrides,
});

describe('AuthResolver', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
});

describe('register', () => {
it('calls registerUseCase and returns a token pair', async () => {
const registerUseCase = makeRegisterUseCase({
execute: vi.fn().mockResolvedValue({ userId: 'user-1', email: 'test@example.com' }),
});
const fastify = makeFastify();
const tokenService = makeTokenService();

const resolver = new AuthResolver({
registerUseCase,
loginUseCase: makeLoginUseCase(),
fastify: fastify as never,
tokenService,
});

const result = await resolver.register('test@example.com', 'password123');
Expand All @@ -42,8 +41,8 @@ describe('AuthResolver', () => {
email: 'test@example.com',
password: 'password123',
});
expect(fastify.jwt.sign).toHaveBeenCalledTimes(2);
expect(result).toEqual({ accessToken: 'signed-token', refreshToken: 'signed-token' });
expect(tokenService.sign).toHaveBeenCalledWith('user-1', 'test@example.com');
expect(result).toEqual({ accessToken: 'access-token', refreshToken: 'refresh-token' });
});
});

Expand All @@ -53,12 +52,12 @@ describe('AuthResolver', () => {
const loginUseCase = makeLoginUseCase({
execute: vi.fn().mockResolvedValue(user),
});
const fastify = makeFastify();
const tokenService = makeTokenService();

const resolver = new AuthResolver({
registerUseCase: makeRegisterUseCase(),
loginUseCase,
fastify: fastify as never,
tokenService,
});

const result = await resolver.login('test@example.com', 'password123');
Expand All @@ -67,42 +66,41 @@ describe('AuthResolver', () => {
email: 'test@example.com',
password: 'password123',
});
expect(fastify.jwt.sign).toHaveBeenCalledTimes(2);
expect(result).toEqual({ accessToken: 'signed-token', refreshToken: 'signed-token' });
expect(tokenService.sign).toHaveBeenCalledWith(user.id, user.email);
expect(result).toEqual({ accessToken: 'access-token', refreshToken: 'refresh-token' });
});
});

describe('refreshToken', () => {
it('verifies the cookie and returns a new token pair', () => {
const fastify = makeFastify();
fastify.jwt.verify.mockReturnValue({ sub: 'user-1', email: 'test@example.com' });
it('verifies the refresh token and returns a new token pair', () => {
const tokenService = makeTokenService({
verifyRefresh: vi.fn().mockReturnValue({ sub: 'user-1', email: 'test@example.com' }),
});

const resolver = new AuthResolver({
registerUseCase: makeRegisterUseCase(),
loginUseCase: makeLoginUseCase(),
fastify: fastify as never,
tokenService,
});

const result = resolver.refreshToken('valid-refresh-token');

expect(fastify.jwt.verify).toHaveBeenCalledWith(
'valid-refresh-token',
expect.objectContaining({ key: expect.any(String) }),
);
expect(fastify.jwt.sign).toHaveBeenCalledTimes(2);
expect(result).toEqual({ accessToken: 'signed-token', refreshToken: 'signed-token' });
expect(tokenService.verifyRefresh).toHaveBeenCalledWith('valid-refresh-token');
expect(tokenService.sign).toHaveBeenCalledWith('user-1', 'test@example.com');
expect(result).toEqual({ accessToken: 'access-token', refreshToken: 'refresh-token' });
});

it('throws UNAUTHORIZED when the refresh token is invalid', () => {
const fastify = makeFastify();
fastify.jwt.verify.mockImplementation(() => {
throw new Error('jwt expired');
const tokenService = makeTokenService({
verifyRefresh: vi.fn().mockImplementation(() => {
throw Object.assign(new Error('Invalid refresh token'), { code: 'UNAUTHORIZED' });
}),
});

const resolver = new AuthResolver({
registerUseCase: makeRegisterUseCase(),
loginUseCase: makeLoginUseCase(),
fastify: fastify as never,
tokenService,
});

const err = (() => {
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/http/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { GetInterviewRoundsUseCase } from '@/use-cases/interviewRounds/GetInterv
import { UpdateInterviewRoundUseCase } from '@/use-cases/interviewRounds/UpdateInterviewRoundUseCase.js';
import { DeleteInterviewRoundUseCase } from '@/use-cases/interviewRounds/DeleteInterviewRoundUseCase.js';
import { GetActivityLogsUseCase } from '@/use-cases/activityLogs/GetActivityLogsUseCase.js';
import { FastifyJwtTokenService } from '@/infrastructure/auth/FastifyJwtTokenService.js';
import { PrismaApiTokenRepository } from '@/infrastructure/db/repositories/PrismaApiTokenRepository.js';
import { ApiTokenMapper } from '@/interface-adapters/mappers/ApiTokenMapper.js';
import { CreateApiTokenUseCase } from '@/use-cases/apiTokens/CreateApiTokenUseCase.js';
Expand All @@ -73,6 +74,7 @@ declare module '@fastify/awilix' {
storageProvider: LocalStorageProvider | R2StorageProvider;
generateId: () => string;
fastify: FastifyInstance;
tokenService: FastifyJwtTokenService;
cache: MemoryCache;

// Raw Prisma repositories (used internally by the cached decorators)
Expand Down Expand Up @@ -147,6 +149,7 @@ export function buildContainer(fastify: FastifyInstance): void {
storageProvider: asClass(StorageProvider, { lifetime: Lifetime.SINGLETON }),
generateId: asValue(() => nanoid()),
fastify: asValue(fastify),
tokenService: asClass(FastifyJwtTokenService, { lifetime: Lifetime.SINGLETON }),
cache: asValue(new MemoryCache()),

// Raw Prisma repositories
Expand Down
32 changes: 32 additions & 0 deletions apps/api/src/infrastructure/auth/FastifyJwtTokenService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { FastifyInstance } from 'fastify';
import type { ITokenService, TokenPair } from '@/use-cases/ports/ITokenService.js';

interface Deps {
fastify: FastifyInstance;
}

export class FastifyJwtTokenService implements ITokenService {
constructor(private readonly deps: Deps) {}

sign(userId: string, email: string): TokenPair {
const accessToken = this.deps.fastify.jwt.sign(
{ sub: userId, email },
{ expiresIn: '15m' },
);
const refreshToken = this.deps.fastify.jwt.sign(
{ sub: userId, email },
{ key: process.env.JWT_REFRESH_SECRET!, expiresIn: '7d' },
);
return { accessToken, refreshToken };
}

verifyRefresh(token: string): { sub: string; email: string } {
try {
return this.deps.fastify.jwt.verify<{ sub: string; email: string }>(token, {
key: process.env.JWT_REFRESH_SECRET!,
});
} catch {
throw Object.assign(new Error('Invalid refresh token'), { code: 'UNAUTHORIZED' });
}
}
}
Comment on lines +8 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== file listing ==\n'
git ls-files 'apps/api/src/**/*' | sed -n '1,200p'

printf '\n== locate JWT_REFRESH_SECRET usage ==\n'
rg -n "JWT_REFRESH_SECRET|refresh secret|JWT_REFRESH" apps/api/src apps/api -g '!**/dist/**' -g '!**/build/**'

printf '\n== inspect target file ==\n'
cat -n apps/api/src/infrastructure/auth/FastifyJwtTokenService.ts

printf '\n== inspect likely env/schema files ==\n'
fd -a -t f '.*(env|schema|config|validate).*' apps/api/src apps/api 2>/dev/null | sed -n '1,200p'

Repository: mankatcheung/job-finder

Length of output: 12864


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== search for env validation/config ==\n'
rg -n "process\.env|JWT_REFRESH_SECRET|zod.*env|env schema|dotenv|createEnv|safeEnv|validate.*env" \
  . \
  -g '!**/dist/**' -g '!**/build/**' -g '!**/node_modules/**'

printf '\n== inspect HTTP/container wiring around token service ==\n'
cat -n apps/api/src/http/container.ts | sed -n '1,260p'

printf '\n== inspect auth plugin ==\n'
cat -n apps/api/src/http/plugins/auth.plugin.ts | sed -n '1,220p'

Repository: mankatcheung/job-finder

Length of output: 18657


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== app bootstrap ==\n'
cat -n apps/api/src/app.ts | sed -n '1,240p'

printf '\n== server entrypoint ==\n'
cat -n apps/api/src/index.ts | sed -n '1,200p'

printf '\n== token service usages ==\n'
rg -n "tokenService|FastifyJwtTokenService|resolve\\(['\"]tokenService|cradle\\.tokenService|diContainer\\.resolve\\(['\"]tokenService" \
  apps/api/src -g '!**/dist/**' -g '!**/build/**'

Repository: mankatcheung/job-finder

Length of output: 6056


Validate JWT_REFRESH_SECRET in the constructor (apps/api/src/infrastructure/auth/FastifyJwtTokenService.ts:8-32).

process.env.JWT_REFRESH_SECRET! is read lazily in sign() and verifyRefresh(), and this service is only resolved when auth flows run, so a missing secret lets the app boot and then breaks the first login/refresh request. Cache it in a private field and throw if it is unset.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/auth/FastifyJwtTokenService.ts` around lines 8 -
32, Update FastifyJwtTokenService’s constructor to read and validate
JWT_REFRESH_SECRET immediately, throwing when it is unset, then cache the
validated value in a private field. Replace the lazy
process.env.JWT_REFRESH_SECRET! reads in sign() and verifyRefresh() with that
field.

33 changes: 6 additions & 27 deletions apps/api/src/interface-adapters/resolvers/AuthResolver.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,28 @@
import type { FastifyInstance } from 'fastify';
import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js';
import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js';
import type { ITokenService, TokenPair } from '@/use-cases/ports/ITokenService.js';

interface Deps {
registerUseCase: IRegisterUseCase;
loginUseCase: ILoginUseCase;
fastify: FastifyInstance;
}

interface TokenPair {
accessToken: string;
refreshToken: string;
tokenService: ITokenService;
}

export class AuthResolver {
constructor(private readonly deps: Deps) {}

async register(email: string, password: string): Promise<TokenPair> {
const { userId } = await this.deps.registerUseCase.execute({ email, password });
return this.signTokens(userId, email);
return this.deps.tokenService.sign(userId, email);
}

async login(email: string, password: string): Promise<TokenPair> {
const user = await this.deps.loginUseCase.execute({ email, password });
return this.signTokens(user.id, user.email);
return this.deps.tokenService.sign(user.id, user.email);
}

refreshToken(refreshToken: string): TokenPair {
let payload: { sub: string; email: string };
try {
payload = this.deps.fastify.jwt.verify<{ sub: string; email: string }>(refreshToken, {
key: process.env.JWT_REFRESH_SECRET!,
});
} catch {
throw Object.assign(new Error('Invalid refresh token'), { code: 'UNAUTHORIZED' });
}
return this.signTokens(payload.sub, payload.email);
}

private signTokens(userId: string, email: string): TokenPair {
const accessToken = this.deps.fastify.jwt.sign({ sub: userId, email }, { expiresIn: '15m' });
const refreshToken = this.deps.fastify.jwt.sign(
{ sub: userId, email },
{ key: process.env.JWT_REFRESH_SECRET!, expiresIn: '7d' },
);
return { accessToken, refreshToken };
const payload = this.deps.tokenService.verifyRefresh(refreshToken);
return this.deps.tokenService.sign(payload.sub, payload.email);
}
}
9 changes: 9 additions & 0 deletions apps/api/src/use-cases/ports/ITokenService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface TokenPair {
accessToken: string;
refreshToken: string;
}

export interface ITokenService {
sign(userId: string, email: string): TokenPair;
verifyRefresh(token: string): { sub: string; email: string };
}
Loading