From fb527dc9efed283b12a4e06919bbdbfa7f894dc2 Mon Sep 17 00:00:00 2001 From: Dreamstore2046 <199616733+Dreamstore2046@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:50:31 +0800 Subject: [PATCH] Harden JWT auth configuration --- backend/src/auth/auth.controller.ts | 19 +++---- backend/src/auth/auth.module.ts | 15 ++++-- backend/src/auth/auth.service.spec.ts | 73 ++++++++++++++++++++++++++ backend/src/auth/auth.service.ts | 75 +++++++++++++++++++++++---- backend/src/auth/jwt.config.ts | 30 +++++++++++ backend/src/auth/jwtConstants.ts | 5 -- 6 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 backend/src/auth/auth.service.spec.ts create mode 100644 backend/src/auth/jwt.config.ts delete mode 100644 backend/src/auth/jwtConstants.ts diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index a13f04c..580e772 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,14 +1,11 @@ -import { Body, Controller, Post } from "@nestjs/common"; -import { AuthService } from "./auth.service"; +import { Controller, Post, UnauthorizedException } from '@nestjs/common'; @Controller('auth') export class AuthController { - constructor( - private authService:AuthService - ) {} - @Post('login') - async login(@Body() payload: any) { - const {expiry, ...authPayload}=payload - return this.authService.login(authPayload, expiry); - } -} \ No newline at end of file + @Post('login') + async login() { + throw new UnauthorizedException( + 'Direct login is disabled until wallet signature verification is implemented', + ); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 8f3e458..2acd9bc 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -2,16 +2,21 @@ import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtModule, JwtService } from '@nestjs/jwt'; import { AuthController } from './auth.controller'; -import jwtConstants from './jwtConstants'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { getJwtSecret } from './jwt.config'; @Module({ imports: [ - JwtModule.register({ + JwtModule.registerAsync({ global: true, - secret:jwtConstants.secret - }) + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: getJwtSecret(configService), + }), + }), ], controllers: [AuthController], - providers: [AuthService, JwtService] + providers: [AuthService, JwtService], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..16fe929 --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -0,0 +1,73 @@ +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { getJwtSecret } from './jwt.config'; + +const JWT_SECRET = 'test-secret-with-at-least-thirty-two-chars'; + +function createConfig(overrides: Record = {}) { + const values = { + JWT_SECRET, + ...overrides, + }; + + return { + get: jest.fn((key: string) => values[key]), + } as unknown as ConfigService; +} + +describe('AuthService', () => { + it('requires JWT_SECRET to come from configuration', () => { + expect(() => getJwtSecret(createConfig({ JWT_SECRET: undefined }))).toThrow( + 'JWT_SECRET must be set', + ); + }); + + it('rejects unsupported caller-controlled JWT claims', async () => { + const service = new AuthService(new JwtService(), createConfig()); + + await expect( + service.login({ signature: 'sig', key: 'key', role: 'admin' }, '10y'), + ).rejects.toThrow(BadRequestException); + }); + + it('signs only the DRep signature claims used by registration', async () => { + const service = new AuthService(new JwtService(), createConfig()); + + const { token } = await service.login( + { signature: ' sig ', key: ' key ' }, + '60s', + ); + const decoded = await service.verifyJWT(token); + + expect(decoded).toMatchObject({ signature: 'sig', key: 'key' }); + expect(decoded.role).toBeUndefined(); + expect(decoded.userId).toBeUndefined(); + }); + + it('rejects tokens issued before JWT_NOT_BEFORE', async () => { + const issuedAt = Math.floor(Date.now() / 1000) - 60; + const token = await new JwtService().signAsync( + { signature: 'sig', key: 'key', iat: issuedAt }, + { secret: JWT_SECRET, expiresIn: '1h' }, + ); + const service = new AuthService( + new JwtService(), + createConfig({ JWT_NOT_BEFORE: String(issuedAt + 1) }), + ); + + await expect(service.verifyJWT(token)).rejects.toThrow( + UnauthorizedException, + ); + }); +}); + +describe('AuthController', () => { + it('does not expose an unauthenticated token minting endpoint', async () => { + const controller = new AuthController(); + + await expect(controller.login()).rejects.toThrow(UnauthorizedException); + }); +}); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 2f67c7c..ad993da 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,29 +1,82 @@ -import { Global, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -import jwtConstants from './jwtConstants'; +import { getJwtNotBefore, getJwtSecret } from './jwt.config'; + +type DrepTokenPayload = { + signature: string; + key: string; +}; + @Injectable() export class AuthService { - constructor(private jwtService: JwtService) {} - async signJWT(payload: any, tte: number | string) { - const accessSecret = jwtConstants.secret; + constructor( + private jwtService: JwtService, + private configService: ConfigService, + ) {} + + async signJWT(payload: DrepTokenPayload, tte: number | string) { + const accessSecret = getJwtSecret(this.configService); return this.jwtService.signAsync(payload, { secret: accessSecret, expiresIn: tte as string, }); } + async verifyJWT(token: string) { - const accessSecret = jwtConstants.secret; - return this.jwtService.verifyAsync(token, { + const accessSecret = getJwtSecret(this.configService); + const decodedToken = await this.jwtService.verifyAsync(token, { secret: accessSecret, }); + + const tokenNotBefore = getJwtNotBefore(this.configService); + if ( + tokenNotBefore !== null && + typeof decodedToken.iat === 'number' && + decodedToken.iat < tokenNotBefore + ) { + throw new UnauthorizedException( + 'JWT was issued before the configured rotation timestamp', + ); + } + + return decodedToken; } - //the payload could consist of the - async login(payload: any, tte: number | string) { - //basically should check if the user signature is valid in the case of a drep or just provide a token for a normal user. - const token = await this.signJWT(payload, tte); + + async login(payload: unknown, tte: number | string) { + const token = await this.signJWT(this.getDrepTokenPayload(payload), tte); return { token }; } + async verifyLogin(token: string) { // should check if there is an existing drep signature in the db. Well this is for dreps who have a profile. } + + private getDrepTokenPayload(payload: unknown): DrepTokenPayload { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new BadRequestException('Invalid login payload'); + } + + const { signature, key, ...extraClaims } = payload as Record; + if (Object.keys(extraClaims).length > 0) { + throw new BadRequestException('Unsupported login claims'); + } + + if (typeof signature !== 'string' || signature.trim().length === 0) { + throw new BadRequestException('Missing DRep signature'); + } + + if (typeof key !== 'string' || key.trim().length === 0) { + throw new BadRequestException('Missing DRep signature key'); + } + + return { + signature: signature.trim(), + key: key.trim(), + }; + } } diff --git a/backend/src/auth/jwt.config.ts b/backend/src/auth/jwt.config.ts new file mode 100644 index 0000000..534205e --- /dev/null +++ b/backend/src/auth/jwt.config.ts @@ -0,0 +1,30 @@ +import { ConfigService } from '@nestjs/config'; + +const MIN_JWT_SECRET_LENGTH = 32; + +export function getJwtSecret(configService: ConfigService): string { + const secret = configService.get('JWT_SECRET'); + + if (!secret || secret.length < MIN_JWT_SECRET_LENGTH) { + throw new Error( + `JWT_SECRET must be set and at least ${MIN_JWT_SECRET_LENGTH} characters long`, + ); + } + + return secret; +} + +export function getJwtNotBefore(configService: ConfigService): number | null { + const tokenNotBefore = configService.get('JWT_NOT_BEFORE'); + if (!tokenNotBefore) return null; + + const numericTimestamp = Number(tokenNotBefore); + if (Number.isFinite(numericTimestamp)) return numericTimestamp; + + const parsedTimestamp = Date.parse(tokenNotBefore); + if (!Number.isNaN(parsedTimestamp)) { + return Math.floor(parsedTimestamp / 1000); + } + + throw new Error('JWT_NOT_BEFORE must be a Unix timestamp or ISO date'); +} diff --git a/backend/src/auth/jwtConstants.ts b/backend/src/auth/jwtConstants.ts deleted file mode 100644 index 651b093..0000000 --- a/backend/src/auth/jwtConstants.ts +++ /dev/null @@ -1,5 +0,0 @@ -//no env file found thus this will reside here for a moment -const jwtConstants = { - secret:'f6193c376fa7037461ef0b1b061c40c556646aef5d72805b4808bc61db73031cea8599910299e9124a32be0cf9b93aed9398c8b0166653bfc8713aa11a64f845' -} -export default jwtConstants \ No newline at end of file