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

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,21 @@
"@types/node": "^20.14.0",
"@types/nodemailer": "^6.4.15",
"@types/passport-jwt":"^4.0.1",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"prisma": "^5.15.0",
"supertest": "^7.0.0",
"ts-jest": "^29.1.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"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 @@ -14,6 +15,7 @@ import { StellarIndexerService } from "./common/stellar-indexer.service";
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
AuthModule,
GroupsModule,
MembersModule,
LoansModule,
Expand Down
11 changes: 10 additions & 1 deletion src/common/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import {
xdr,
} from "@stellar/stellar-sdk";

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

interface HorizonAccount {
balances?: HorizonBalance[];
}

@Injectable()
export class StellarService {
private readonly logger = new Logger(StellarService.name);
Expand Down Expand Up @@ -60,7 +69,7 @@ export class StellarService {
try {
if (!assetContractId) {
const account = await this.server.getAccount(address);
return account.balances
return (((account as unknown) as HorizonAccount).balances ?? [])
.find((b) => b.asset_type === "native")
?.balance ?? "0";
}
Expand Down
75 changes: 75 additions & 0 deletions src/modules/auth/auth-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { Keypair } from "@stellar/stellar-sdk";
import * as request from "supertest";
import { AppModule } from "../../app.module";
import { PrismaService } from "../../common/prisma.service";

describe("Stellar JWT auth flow", () => {
let app: INestApplication;
const admin = Keypair.random();

const prismaMock = {
$connect: jest.fn(),
$disconnect: jest.fn(),
group: {
create: jest.fn(),
},
member: {
findUnique: jest.fn(),
},
loan: {
create: jest.fn(),
update: jest.fn(),
findUnique: jest.fn(),
},
notification: {
create: jest.fn(),
},
};

beforeAll(async () => {
process.env.JWT_SECRET = "test-secret";

const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaService)
.useValue(prismaMock)
.compile();

app = moduleRef.createNestApplication();
app.setGlobalPrefix("api");
await app.init();
});

afterAll(async () => {
await app.close();
});

it("issues a nonce, verifies a Stellar signature, and authorizes a protected group write", async () => {
const nonceResponse = await request(app.getHttpServer())
.get("/api/auth/nonce")
.query({ address: admin.publicKey() })
.expect(200);

const signature = admin.sign(Buffer.from(nonceResponse.body.nonce, "utf8")).toString("base64");

const verifyResponse = await request(app.getHttpServer())
.post("/api/auth/verify")
.send({ address: admin.publicKey(), signedNonce: signature })
.expect(201);

prismaMock.group.create.mockResolvedValueOnce({
id: "group_1",
name: "Builders Coop",
adminAddress: admin.publicKey(),
});

await request(app.getHttpServer())
.post("/api/groups")
.set("Authorization", `Bearer ${verifyResponse.body.accessToken}`)
.send({ name: "Builders Coop", adminAddress: admin.publicKey() })
.expect(201);
});
});
26 changes: 26 additions & 0 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { AuthService } from "./auth.service";

export interface VerifyNonceDto {
address: string;
signedNonce: string;
}

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

@Get("nonce")
@ApiOperation({ summary: "Issue a short-lived nonce for Stellar signature auth" })
nonce(@Query("address") address: string) {
return this.authService.issueNonce(address);
}

@Post("verify")
@ApiOperation({ summary: "Verify a signed Stellar nonce and issue a JWT" })
verify(@Body() dto: VerifyNonceDto) {
return this.authService.verifyNonce(dto.address, dto.signedNonce);
}
}
26 changes: 26 additions & 0 deletions src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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: [
ConfigModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>("JWT_SECRET", "change-me-in-production"),
signOptions: { expiresIn: "1h" },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
102 changes: 102 additions & 0 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { Keypair } from "@stellar/stellar-sdk";
import { randomUUID } from "crypto";

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

export interface AuthToken {
accessToken: string;
}

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

constructor(
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}

issueNonce(address: string): { nonce: string; expiresAt: string } {
const normalizedAddress = this.normalizeAddress(address);
this.assertValidAddress(normalizedAddress);

const nonce = `coopfinance:${normalizedAddress}:${Date.now()}:${randomUUID()}`;
const expiresAt = Date.now() + this.nonceTtlMs;
this.nonces.set(normalizedAddress, { nonce, expiresAt });
this.cleanupExpiredNonces();

return { nonce, expiresAt: new Date(expiresAt).toISOString() };
}

verifyNonce(address: string, signedNonce: string): AuthToken {
const normalizedAddress = this.normalizeAddress(address);
this.assertValidAddress(normalizedAddress);

const stored = this.nonces.get(normalizedAddress);
if (!stored) {
throw new UnauthorizedException("Nonce not found or already used");
}
if (Date.now() > stored.expiresAt) {
this.nonces.delete(normalizedAddress);
throw new UnauthorizedException("Nonce expired");
}

const signature = this.decodeSignature(signedNonce);
const isValid = Keypair.fromPublicKey(normalizedAddress).verify(
Buffer.from(stored.nonce, "utf8"),
signature,
);
if (!isValid) {
throw new UnauthorizedException("Invalid Stellar signature");
}

this.nonces.delete(normalizedAddress);
return {
accessToken: this.jwtService.sign({
sub: normalizedAddress,
address: normalizedAddress,
}),
};
}

private normalizeAddress(address: string): string {
return String(address ?? "").trim();
}

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

private decodeSignature(signedNonce: string): Buffer {
const value = String(signedNonce ?? "").trim();
if (!value) {
throw new BadRequestException("signedNonce is required");
}

const withoutHexPrefix = value.startsWith("0x") ? value.slice(2) : value;
if (/^[0-9a-fA-F]+$/.test(withoutHexPrefix) && withoutHexPrefix.length % 2 === 0) {
return Buffer.from(withoutHexPrefix, "hex");
}
return Buffer.from(value, "base64");
}

private cleanupExpiredNonces(): void {
const now = Date.now();
for (const [address, nonce] of this.nonces) {
if (nonce.expiresAt <= now) {
this.nonces.delete(address);
}
}
}
}
7 changes: 7 additions & 0 deletions src/modules/auth/auth.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface AuthenticatedUser {
address: string;
}

export interface AuthenticatedRequest {
user: AuthenticatedUser;
}
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") {}
25 changes: 25 additions & 0 deletions src/modules/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { AuthenticatedUser } from "./auth.types";

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

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

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

@ApiTags("groups")
Expand All @@ -20,8 +22,13 @@ export class GroupsController {
}

@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Register a new group after contract deployment" })
create(@Body() dto: CreateGroupDto) {
create(@Body() dto: CreateGroupDto, @Req() req: AuthenticatedRequest) {
if (dto.adminAddress !== req.user.address) {
throw new ForbiddenException("Authenticated Stellar address must match adminAddress");
}
return this.groupsService.create(dto);
}
}
22 changes: 16 additions & 6 deletions src/modules/loans/loans.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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, Req, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { AuthenticatedRequest } from "../auth/auth.types";
import { LoansService, CreateLoanDto } from "./loans.service";

@ApiTags("loans")
Expand All @@ -21,14 +23,22 @@ 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);
create(@Body() dto: CreateLoanDto, @Req() req: AuthenticatedRequest) {
return this.loansService.create(dto, req.user.address);
}

@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);
updateStatus(
@Param("id") id: string,
@Body("status") status: string,
@Req() req: AuthenticatedRequest,
) {
return this.loansService.updateStatus(id, status, req.user.address);
}
}
Loading