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
9,216 changes: 9,216 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,15 @@
"ts-jest": "^29.1.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"testEnvironment": "node"
}
}
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +20,7 @@ import { StellarIndexerService } from "./common/stellar-indexer.service";
LoansModule,
GovernanceModule,
NotificationsModule,
AuthModule,
],
providers: [PrismaService, StellarService, StellarIndexerService],
})
Expand Down
11 changes: 7 additions & 4 deletions src/common/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
xdr,
} from "@stellar/stellar-sdk";

interface HorizonBalance {
asset_type: string;
balance: string;
}

@Injectable()
export class StellarService {
private readonly logger = new Logger(StellarService.name);
Expand Down Expand Up @@ -59,10 +64,8 @@ export class StellarService {
async getBalance(address: string, assetContractId?: string): Promise<string> {
try {
if (!assetContractId) {
const account = await this.server.getAccount(address);
return account.balances
.find((b) => b.asset_type === "native")
?.balance ?? "0";
const account = await this.server.getAccount(address) as unknown as { balances?: HorizonBalance[] };
return account.balances?.find((b) => b.asset_type === "native")?.balance ?? "0";
}
// USDC or other token contract balance
const result = await this.readContract(
Expand Down
31 changes: 31 additions & 0 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { ApiBody, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { AuthService } from "./auth.service";

@ApiTags("auth")
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Get("nonce")
@ApiOperation({ summary: "Create a nonce for Stellar wallet authentication" })
@ApiQuery({ name: "address", required: true })
nonce(@Query("address") address: string) {
return this.authService.issueNonce(address);
}

@Post("verify")
@ApiOperation({ summary: "Verify a signed nonce and issue a JWT" })
@ApiBody({
schema: {
properties: {
address: { type: "string" },
signedNonce: { type: "string", description: "Base64-encoded Stellar signature" },
},
required: ["address", "signedNonce"],
},
})
verify(@Body("address") address: string, @Body("signedNonce") signedNonce: string) {
return this.authService.verify(address, signedNonce);
}
}
25 changes: 25 additions & 0 deletions src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";

@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>("JWT_SECRET") || "change-me-in-production",
signOptions: { expiresIn: "1h" },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
29 changes: 29 additions & 0 deletions src/modules/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { JwtService } from "@nestjs/jwt";
import { Keypair } from "@stellar/stellar-sdk";
import { AuthService } from "./auth.service";

describe("AuthService", () => {
const jwtService = new JwtService({ secret: "test-secret" });

it("issues a JWT after verifying a signed nonce", () => {
const keypair = Keypair.random();
const service = new AuthService(jwtService);
const { nonce } = service.issueNonce(keypair.publicKey());
const signedNonce = keypair.sign(Buffer.from(nonce)).toString("base64");

const result = service.verify(keypair.publicKey(), signedNonce);

expect(result.address).toBe(keypair.publicKey());
expect(jwtService.verify(result.accessToken).address).toBe(keypair.publicKey());
});

it("rejects a signature from a different Stellar keypair", () => {
const owner = Keypair.random();
const attacker = Keypair.random();
const service = new AuthService(jwtService);
const { nonce } = service.issueNonce(owner.publicKey());
const signedNonce = attacker.sign(Buffer.from(nonce)).toString("base64");

expect(() => service.verify(owner.publicKey(), signedNonce)).toThrow("Invalid nonce signature");
});
});
81 changes: 81 additions & 0 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { randomBytes } from "crypto";
import { Keypair } from "@stellar/stellar-sdk";

interface StoredNonce {
nonce: string;
expiresAt: number;
}

export interface JwtPayload {
sub: string;
address: string;
}

@Injectable()
export class AuthService {
private readonly nonces = new Map<string, StoredNonce>();
private readonly ttlMs = 5 * 60 * 1000;

constructor(private readonly jwtService: JwtService) {}

issueNonce(address: string) {
this.assertAddress(address);

const nonce = randomBytes(24).toString("hex");
this.nonces.set(address, {
nonce,
expiresAt: Date.now() + this.ttlMs,
});

return { address, nonce, expiresInSeconds: this.ttlMs / 1000 };
}

verify(address: string, signedNonce: string) {
this.assertAddress(address);

const stored = this.nonces.get(address);
if (!stored || stored.expiresAt < Date.now()) {
this.nonces.delete(address);
throw new UnauthorizedException("Nonce is missing or expired");
}

const signature = this.decodeSignature(signedNonce);
const keypair = Keypair.fromPublicKey(address);
const valid = keypair.verify(Buffer.from(stored.nonce), signature);

if (!valid) {
throw new UnauthorizedException("Invalid nonce signature");
}

this.nonces.delete(address);

const payload: JwtPayload = { sub: address, address };
return { accessToken: this.jwtService.sign(payload), address };
}

private assertAddress(address?: string) {
if (!address) {
throw new BadRequestException("address is required");
}

try {
Keypair.fromPublicKey(address);
} catch {
throw new BadRequestException("address must be a valid Stellar public key");
}
}

private decodeSignature(signedNonce: string) {
if (!signedNonce) {
throw new BadRequestException("signedNonce is required");
}

try {
return Buffer.from(signedNonce, "base64");
} catch {
throw new BadRequestException("signedNonce must be a base64 signature");
}
}
}
5 changes: 5 additions & 0 deletions src/modules/auth/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
20 changes: 20 additions & 0 deletions src/modules/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { JwtPayload } from "./auth.service";

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>("JWT_SECRET") || "change-me-in-production",
});
}

validate(payload: JwtPayload) {
return { address: payload.address, sub: payload.sub };
}
}
7 changes: 5 additions & 2 deletions src/modules/groups/groups.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Param, Body } from "@nestjs/common";
import { ApiTags, ApiOperation } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { GroupsService, CreateGroupDto } from "./groups.service";

@ApiTags("groups")
Expand All @@ -21,6 +22,8 @@ export class GroupsController {

@Post()
@ApiOperation({ summary: "Register a new group after contract deployment" })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
create(@Body() dto: CreateGroupDto) {
return this.groupsService.create(dto);
}
Expand Down
9 changes: 7 additions & 2 deletions src/modules/loans/loans.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Patch, Param, Body, Query } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiQuery } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { LoansService, CreateLoanDto } from "./loans.service";

@ApiTags("loans")
Expand All @@ -22,12 +23,16 @@ export class LoansController {

@Post()
@ApiOperation({ summary: "Register a loan request after on-chain submission" })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
create(@Body() dto: CreateLoanDto) {
return this.loansService.create(dto);
}

@Patch(":id/status")
@ApiOperation({ summary: "Update loan status (after on-chain approval/repayment)" })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
updateStatus(@Param("id") id: string, @Body("status") status: string) {
return this.loansService.updateStatus(id, status);
}
Expand Down
9 changes: 8 additions & 1 deletion src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../../common/prisma.service";

export type NotificationType =
Expand All @@ -23,7 +24,13 @@ export class NotificationsService {
metadata?: Record<string, unknown>
) {
return this.prisma.notification.create({
data: { recipient, type, title, body, metadata },
data: {
recipient,
type,
title,
body,
metadata: metadata as Prisma.InputJsonValue | undefined,
},
});
}

Expand Down