Skip to content
Open
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
13 changes: 10 additions & 3 deletions backend/src/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Admin } from './admin.entity';
import { AdminService } from './admin.service';
import { AdminController } from './admin.controller';
Expand All @@ -14,9 +15,15 @@ import { AdminRole } from './admin-role.enum';
@Module({
imports: [
TypeOrmModule.forFeature([Admin]),
JwtModule.register({
secret: process.env.JWT_SECRET || 'supersecret',
signOptions: { expiresIn: '1d' },
// No fallback secret: JWT_SECRET is required by the global config
// validation (see AppModule), so startup fails if it is absent.
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '1d' },
}),
}),
],
controllers: [AdminController],
Expand Down
62 changes: 62 additions & 0 deletions backend/src/admin/strategies/jwt.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ConfigService } from '@nestjs/config';
import { JwtStrategy } from './jwt.strategy';
import { AdminService } from '../admin.service';

/**
* Production startup must never be able to use a predictable JWT secret:
* the strategy reads JWT_SECRET through ConfigService.getOrThrow, so
* constructing it without a configured secret must fail immediately.
*/
describe('Admin JwtStrategy (no fallback secret)', () => {
const adminServiceMock = {
findByEmail: jest.fn(),
} as unknown as AdminService;

function makeConfig(secret?: string): ConfigService {
return {
getOrThrow: jest.fn((key: string) => {
if (key === 'JWT_SECRET' && secret !== undefined) {
return secret;
}
throw new Error('Config variable "JWT_SECRET" is required');
}),
} as unknown as ConfigService;
}

it('fails to construct when JWT_SECRET is absent', () => {
expect(() => new JwtStrategy(adminServiceMock, makeConfig())).toThrow(
/JWT_SECRET/,
);
});

it('constructs when JWT_SECRET is configured', () => {
expect(
() => new JwtStrategy(adminServiceMock, makeConfig('a-real-secret')),
).not.toThrow();
});

it('uses the configured secret, never a hard-coded default', async () => {
const strategy = new JwtStrategy(
adminServiceMock,
makeConfig('configured-secret-value'),
);
// passport-jwt resolves the secret through its internal provider;
// assert it is exactly what configuration provided — never
// 'supersecret' or another predictable default.
const resolved = await new Promise<string>((resolve) => {
(strategy as unknown as {
_secretOrKeyProvider: (
_req: unknown,
_token: string,
done: (err: unknown, secret: string) => void,
) => void;
})._secretOrKeyProvider({}, 'token', (_err, secret) => resolve(secret));
});
expect(resolved).toBe('configured-secret-value');
expect(resolved).not.toBe('supersecret');
});

it('rejects an empty JWT_SECRET as if it were missing', () => {
expect(() => new JwtStrategy(adminServiceMock, makeConfig(''))).toThrow();
});
});
9 changes: 7 additions & 2 deletions backend/src/admin/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AdminService } from '../admin.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'admin-jwt') {
constructor(private adminService: AdminService) {
constructor(
private adminService: AdminService,
configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'supersecret',
// No fallback secret: fail fast when JWT_SECRET is not configured.
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
});
}

Expand Down
62 changes: 62 additions & 0 deletions backend/src/auth/strategies/jwt.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ConfigService } from '@nestjs/config';
import { JwtStrategy } from './jwt.strategy';
import { AuthService } from '../services/auth.service';

/**
* The user JWT strategy must never silently fall back to a predictable
* secret: it resolves JWT_SECRET via ConfigService.getOrThrow, so building
* it without a configured secret fails at startup instead.
*/
describe('User JwtStrategy (no fallback secret)', () => {
const authServiceMock = {
validateUser: jest.fn(),
} as unknown as AuthService;

function makeConfig(secret?: string): ConfigService {
return {
getOrThrow: jest.fn((key: string) => {
if (key === 'JWT_SECRET' && secret !== undefined) {
return secret;
}
throw new Error('Config variable "JWT_SECRET" is required');
}),
} as unknown as ConfigService;
}

it('fails to construct when JWT_SECRET is absent', () => {
expect(() => new JwtStrategy(authServiceMock, makeConfig())).toThrow(
/JWT_SECRET/,
);
});

it('constructs when JWT_SECRET is configured', () => {
expect(
() => new JwtStrategy(authServiceMock, makeConfig('a-real-secret')),
).not.toThrow();
});

it('uses the configured secret, never a hard-coded default', async () => {
const strategy = new JwtStrategy(
authServiceMock,
makeConfig('configured-secret-value'),
);
// passport-jwt resolves the secret through its internal provider;
// assert it is exactly what configuration provided — never
// 'your-secret-key' or another predictable default.
const resolved = await new Promise<string>((resolve) => {
(strategy as unknown as {
_secretOrKeyProvider: (
_req: unknown,
_token: string,
done: (err: unknown, secret: string) => void,
) => void;
})._secretOrKeyProvider({}, 'token', (_err, secret) => resolve(secret));
});
expect(resolved).toBe('configured-secret-value');
expect(resolved).not.toBe('your-secret-key');
});

it('rejects an empty JWT_SECRET as if it were missing', () => {
expect(() => new JwtStrategy(authServiceMock, makeConfig(''))).toThrow();
});
});
3 changes: 2 additions & 1 deletion backend/src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET') || 'your-secret-key',
// No fallback secret: fail fast when JWT_SECRET is not configured.
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
});
}

Expand Down
Loading