Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { AuthModule } from "./modules/auth/auth.module";
import { GroupsModule } from "./modules/groups/groups.module";
import { MembersModule } from "./modules/members/members.module";
import { LoansModule } from "./modules/loans/loans.module";
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
35 changes: 35 additions & 0 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Controller, Get, Post, Body, Query } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiQuery } from "@nestjs/swagger";
import { AuthService } from "./auth.service";

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

@Get("nonce")
@ApiOperation({
summary: "Get a challenge nonce for Stellar wallet authentication",
description:
"Returns a unique message that the client must sign with their " +
"Stellar private key. The signature is then submitted to POST /api/auth/login.",
})
@ApiQuery({ name: "address", required: true, description: "Stellar public key (G…)" })
getNonce(@Query("address") address: string) {
return this.authService.generateNonce(address);
}

@Post("login")
@ApiOperation({
summary: "Verify Stellar signature and issue a JWT",
description:
"Submit a base64-encoded signature of the nonce challenge message. " +
"Returns a JWT access token for use in the Authorization header.",
})
login(
@Body("address") address: string,
@Body("signature") signature: string,
) {
return this.authService.login(address, signature);
}
}
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 { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { ConfigService } from "@nestjs/config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";

@Module({
imports: [
PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get("JWT_SECRET", "coopfin-jwt-secret-dev"),
signOptions: {
expiresIn: config.get("JWT_EXPIRES_IN", "24h"),
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule, PassportModule],
})
export class AuthModule {}
126 changes: 126 additions & 0 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Injectable, UnauthorizedException, Logger } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import { Keypair } from "@stellar/stellar-sdk";
import { randomBytes } from "crypto";

// ─── In-memory nonce store ────────────────────────────────────────────
// Nonces expire after 5 minutes. In production, replace with Redis.

interface NonceEntry {
message: string;
expiresAt: number;
}

@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly nonces = new Map<string, NonceEntry>();
private readonly NONCE_TTL_MS = 5 * 60 * 1000; // 5 minutes

constructor(
private jwtService: JwtService,
private config: ConfigService,
) {
// Purge expired nonces every 60 seconds
setInterval(() => this.purgeExpiredNonces(), 60_000);
}

/**
* Generate a challenge nonce for a Stellar address.
* The client must sign this message to prove ownership of the private key.
*/
generateNonce(address: string): { nonce: string; message: string } {
const nonce = randomBytes(32).toString("hex");
const timestamp = Date.now();
const message = `CoopFinance auth challenge: ${nonce}\nAddress: ${address}\nTimestamp: ${timestamp}\nExpires: ${timestamp + this.NONCE_TTL_MS}`;

this.nonces.set(address, {
message,
expiresAt: timestamp + this.NONCE_TTL_MS,
});

this.logger.debug(`Nonce generated for ${address}`);
return { nonce, message };
}

/**
* Verify a Stellar signature against the nonce challenge and issue a JWT.
*
* @param address - Stellar public key (G…)
* @param signature - Base64-encoded signature of the challenge message
*/
async login(
address: string,
signature: string,
): Promise<{ accessToken: string }> {
const entry = this.nonces.get(address);
if (!entry) {
throw new UnauthorizedException(
"No active nonce found. Request GET /api/auth/nonce?address=... first.",
);
}

if (Date.now() > entry.expiresAt) {
this.nonces.delete(address);
throw new UnauthorizedException("Nonce expired. Request a new one.");
}

// ── Verify Stellar signature ────────────────────────────────────
const isValid = this.verifyStellarSignature(
address,
entry.message,
signature,
);

if (!isValid) {
this.logger.warn(`Invalid signature for ${address}`);
throw new UnauthorizedException("Signature verification failed.");
}

// Consume the nonce (one-time use)
this.nonces.delete(address);

// ── Issue JWT ───────────────────────────────────────────────────
const payload = {
sub: address,
address,
iat: Math.floor(Date.now() / 1000),
};

const accessToken = this.jwtService.sign(payload);

this.logger.log(`JWT issued for ${address}`);
return { accessToken };
}

/**
* Verify that `signature` is a valid Stellar signature of `message`
* by the keypair identified by `address`.
*/
private verifyStellarSignature(
address: string,
message: string,
signature: string,
): boolean {
try {
const keypair = Keypair.fromPublicKey(address);
const messageBytes = Buffer.from(message, "utf-8");
const signatureBytes = Buffer.from(signature, "base64");
return keypair.verify(messageBytes, signatureBytes);
} catch (err) {
this.logger.error("Signature verification error", err);
return false;
}
}

/** Remove expired nonces from the in-memory store. */
private purgeExpiredNonces(): void {
const now = Date.now();
for (const [key, entry] of this.nonces) {
if (now > entry.expiresAt) {
this.nonces.delete(key);
}
}
}
}
12 changes: 12 additions & 0 deletions src/modules/auth/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Injectable, ExecutionContext } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Observable } from "rxjs";

@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
return super.canActivate(context);
}
}
30 changes: 30 additions & 0 deletions src/modules/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";

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

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get("JWT_SECRET", "coopfin-jwt-secret-dev"),
});
}

validate(payload: JwtPayload): JwtPayload {
// Passport attaches the return value to `request.user`
return {
sub: payload.sub,
address: payload.address,
iat: payload.iat,
};
}
}
7 changes: 5 additions & 2 deletions src/modules/governance/governance.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Controller, Get, Post, Param, Body, Query } from "@nestjs/common";
import { ApiTags, ApiOperation } from "@nestjs/swagger";
import { Controller, Get, Post, Param, Body, Query, UseGuards } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import { GovernanceService, CreateProposalDto } from "./governance.service";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";

@ApiTags("governance")
@Controller("governance")
Expand All @@ -19,6 +20,8 @@ export class GovernanceController {
}

@Post("proposals")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Register proposal after on-chain creation" })
create(@Body() dto: CreateProposalDto) {
return this.governanceService.create(dto);
Expand Down
7 changes: 5 additions & 2 deletions src/modules/groups/groups.controller.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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);
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,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")
Expand All @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/modules/members/members.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Controller, Get, Post, Param, Body } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { Controller, Get, Post, Param, Body, UseGuards } from "@nestjs/common";
import { ApiTags, ApiBearerAuth } from "@nestjs/swagger";
import { MembersService } from "./members.service";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";

@ApiTags("members")
@Controller("groups/:groupId/members")
Expand All @@ -13,6 +14,8 @@ export class MembersController {
}

@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
add(
@Param("groupId") groupId: string,
@Body("address") address: string,
Expand Down
Loading