From 0a829b41a3c7dea022433e685db89352c5d2b609 Mon Sep 17 00:00:00 2001 From: Ishant5436 Date: Thu, 2 Jul 2026 00:08:32 +0530 Subject: [PATCH 1/2] feat: Add JWT authentication to admin endpoints --- package.json | 2 + src/app.module.ts | 2 + src/common/stellar.service.ts | 4 +- src/modules/auth/auth.controller.ts | 33 +++++++++ src/modules/auth/auth.module.ts | 25 +++++++ src/modules/auth/auth.service.ts | 48 +++++++++++++ src/modules/auth/jwt-auth.guard.ts | 5 ++ src/modules/auth/jwt.strategy.ts | 18 +++++ src/modules/groups/groups.controller.ts | 7 +- src/modules/loans/loans.controller.ts | 9 ++- .../notifications/notifications.service.ts | 2 +- test/auth.e2e-spec.ts | 72 +++++++++++++++++++ test/jest-e2e.json | 9 +++ 13 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/modules/auth/auth.controller.ts create mode 100644 src/modules/auth/auth.module.ts create mode 100644 src/modules/auth/auth.service.ts create mode 100644 src/modules/auth/jwt-auth.guard.ts create mode 100644 src/modules/auth/jwt.strategy.ts create mode 100644 test/auth.e2e-spec.ts create mode 100644 test/jest-e2e.json diff --git a/package.json b/package.json index d039a25..90260f0 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,10 @@ "@types/node": "^20.14.0", "@types/nodemailer": "^6.4.15", "@types/passport-jwt":"^4.0.1", + "@types/supertest": "^7.2.0", "jest": "^29.7.0", "prisma": "^5.15.0", + "supertest": "^7.2.2", "ts-jest": "^29.1.5", "ts-node": "^10.9.2", "typescript": "^5.4.5" diff --git a/src/app.module.ts b/src/app.module.ts index 742172e..1742288 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { MembersModule } from "./modules/members/members.module"; import { LoansModule } from "./modules/loans/loans.module"; import { GovernanceModule } from "./modules/governance/governance.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { AuthModule } from "./modules/auth/auth.module"; import { PrismaService } from "./common/prisma.service"; import { StellarService } from "./common/stellar.service"; import { StellarIndexerService } from "./common/stellar-indexer.service"; @@ -19,6 +20,7 @@ import { StellarIndexerService } from "./common/stellar-indexer.service"; LoansModule, GovernanceModule, NotificationsModule, + AuthModule, ], providers: [PrismaService, StellarService, StellarIndexerService], }) diff --git a/src/common/stellar.service.ts b/src/common/stellar.service.ts index d22b1bc..0a94dd2 100644 --- a/src/common/stellar.service.ts +++ b/src/common/stellar.service.ts @@ -59,9 +59,9 @@ export class StellarService { async getBalance(address: string, assetContractId?: string): Promise { try { if (!assetContractId) { - const account = await this.server.getAccount(address); + const account: any = await this.server.getAccount(address); return account.balances - .find((b) => b.asset_type === "native") + ?.find((b: any) => b.asset_type === "native") ?.balance ?? "0"; } // USDC or other token contract balance diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..097b611 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Post, Body, Query, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { AuthService } from './auth.service'; + +class VerifyDto { + address: string; + signedNonce: string; +} + +@ApiTags('auth') +@Controller('api/auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Get('nonce') + @ApiOperation({ summary: 'Get a random nonce for signing' }) + getNonce(@Query('address') address: string) { + if (!address) { + throw new BadRequestException('Address is required'); + } + const nonce = this.authService.generateNonce(address); + return { nonce }; + } + + @Post('verify') + @ApiOperation({ summary: 'Verify signature and issue JWT' }) + verify(@Body() body: VerifyDto) { + if (!body.address || !body.signedNonce) { + throw new BadRequestException('Address and signedNonce are required'); + } + return this.authService.verifySignature(body.address, body.signedNonce); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..ec11b34 --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { JwtStrategy } from './jwt.strategy'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +@Module({ + imports: [ + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET') || 'fallback_secret', + signOptions: { expiresIn: '60m' }, + }), + inject: [ConfigService], + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..84c6477 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,48 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Keypair } from '@stellar/stellar-sdk'; +import * as crypto from 'crypto'; + +@Injectable() +export class AuthService { + private nonceMap = new Map(); + + constructor(private jwtService: JwtService) {} + + generateNonce(address: string): string { + const nonce = crypto.randomBytes(32).toString('hex'); + // 5 minutes TTL + const expiresAt = Date.now() + 5 * 60 * 1000; + this.nonceMap.set(address, { nonce, expiresAt }); + return nonce; + } + + verifySignature(address: string, signedNonce: string): { accessToken: string } { + const stored = this.nonceMap.get(address); + if (!stored) { + throw new UnauthorizedException('Nonce not found or expired'); + } + + if (Date.now() > stored.expiresAt) { + this.nonceMap.delete(address); + throw new UnauthorizedException('Nonce expired'); + } + + try { + const keypair = Keypair.fromPublicKey(address); + const isValid = keypair.verify(Buffer.from(stored.nonce), Buffer.from(signedNonce, 'base64')); + if (!isValid) { + throw new UnauthorizedException('Invalid signature'); + } + } catch (e) { + throw new UnauthorizedException('Invalid signature or address'); + } + + this.nonceMap.delete(address); + + const payload = { sub: address, address }; + return { + accessToken: this.jwtService.sign(payload), + }; + } +} diff --git a/src/modules/auth/jwt-auth.guard.ts b/src/modules/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/src/modules/auth/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/src/modules/auth/jwt.strategy.ts b/src/modules/auth/jwt.strategy.ts new file mode 100644 index 0000000..a37262b --- /dev/null +++ b/src/modules/auth/jwt.strategy.ts @@ -0,0 +1,18 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET || 'fallback_secret', + }); + } + + async validate(payload: any) { + return { address: payload.address }; + } +} diff --git a/src/modules/groups/groups.controller.ts b/src/modules/groups/groups.controller.ts index 36b5907..a53dc8d 100644 --- a/src/modules/groups/groups.controller.ts +++ b/src/modules/groups/groups.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Param, Body } from "@nestjs/common"; -import { ApiTags, ApiOperation } from "@nestjs/swagger"; +import { Controller, Get, Post, Param, Body, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; import { GroupsService, CreateGroupDto } from "./groups.service"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @ApiTags("groups") @Controller("groups") @@ -20,6 +21,8 @@ export class GroupsController { } @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() @ApiOperation({ summary: "Register a new group after contract deployment" }) create(@Body() dto: CreateGroupDto) { return this.groupsService.create(dto); diff --git a/src/modules/loans/loans.controller.ts b/src/modules/loans/loans.controller.ts index 934748b..efe9fb7 100644 --- a/src/modules/loans/loans.controller.ts +++ b/src/modules/loans/loans.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Patch, Param, Body, Query } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiQuery } from "@nestjs/swagger"; +import { Controller, Get, Post, Patch, Param, Body, Query, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiQuery, ApiBearerAuth } from "@nestjs/swagger"; import { LoansService, CreateLoanDto } from "./loans.service"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @ApiTags("loans") @Controller("loans") @@ -21,12 +22,16 @@ export class LoansController { } @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() @ApiOperation({ summary: "Register a loan request after on-chain submission" }) create(@Body() dto: CreateLoanDto) { return this.loansService.create(dto); } @Patch(":id/status") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() @ApiOperation({ summary: "Update loan status (after on-chain approval/repayment)" }) updateStatus(@Param("id") id: string, @Body("status") status: string) { return this.loansService.updateStatus(id, status); diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index 246101d..49ccc18 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -23,7 +23,7 @@ export class NotificationsService { metadata?: Record ) { return this.prisma.notification.create({ - data: { recipient, type, title, body, metadata }, + data: { recipient, type, title, body, metadata: metadata as any }, }); } diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts new file mode 100644 index 0000000..03c7e9c --- /dev/null +++ b/test/auth.e2e-spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; +import { PrismaService } from './../src/common/prisma.service'; +import { Keypair } from '@stellar/stellar-sdk'; + +describe('AuthController (e2e)', () => { + let app: INestApplication; + const keypair = Keypair.random(); + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(PrismaService) + .useValue({ + $connect: jest.fn(), + $disconnect: jest.fn(), + }) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('/api/auth/nonce (GET)', async () => { + const address = keypair.publicKey(); + const res = await request(app.getHttpServer()) + .get(`/api/auth/nonce?address=${address}`) + .expect(200); + + expect(res.body.nonce).toBeDefined(); + }); + + it('/api/auth/verify (POST)', async () => { + const address = keypair.publicKey(); + + // Get nonce + const nonceRes = await request(app.getHttpServer()) + .get(`/api/auth/nonce?address=${address}`) + .expect(200); + + const nonce = nonceRes.body.nonce; + + // Sign nonce + const signature = keypair.sign(Buffer.from(nonce)).toString('base64'); + + // Verify + const verifyRes = await request(app.getHttpServer()) + .post('/api/auth/verify') + .send({ address, signedNonce: signature }) + .expect(201); + + expect(verifyRes.body.accessToken).toBeDefined(); + + // Verify token can be used on protected route + await request(app.getHttpServer()) + .post('/groups') + .set('Authorization', `Bearer ${verifyRes.body.accessToken}`) + .send({}) + .expect((res: any) => { + if (res.status === 401) { + throw new Error('Expected authenticated response, got 401'); + } + }); + }); +}); diff --git a/test/jest-e2e.json b/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} From 3729b3a3f343c52f38fccb1c7011b217aa40d002 Mon Sep 17 00:00:00 2001 From: Ishant5436 Date: Thu, 2 Jul 2026 00:08:32 +0530 Subject: [PATCH 2/2] chore: remove stray fix.py placeholder --- fix.py | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 fix.py diff --git a/fix.py b/fix.py deleted file mode 100644 index 010f957..0000000 --- a/fix.py +++ /dev/null @@ -1,4 +0,0 @@ -# Auto fix for coopfinance/coopfin-api#5 -# 1782899647 - -print("fix #5")