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
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Security Policy

For the authentication and authorization model (roles, scopes, JWT flow,
API-key namespaces, cookie attributes, and route guards) see
[docs/SECURITY-MODEL.md](docs/SECURITY-MODEL.md).

## Supported Versions

Only the current `main` branch and the last tagged release are supported with security updates.
Expand Down
6 changes: 5 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,11 @@ The Swagger documentation provides:
- Complete endpoint specifications
- Request/response schemas
- Interactive API testing
- Authentication details (when implemented)
- Authentication details

For the full auth model (challenge-signature-JWT flow, role-scope table,
API-key namespaces, cookie attributes) see
[docs/SECURITY-MODEL.md](../docs/SECURITY-MODEL.md).

## Project Structure

Expand Down
24 changes: 24 additions & 0 deletions backend/migrations/1795000000000_loan-events-type-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Issue #1194: Port the orphan SQL migration from src/db/migrations/ into the
* real migrations directory so node-pg-migrate actually applies it.
*
* CREATE INDEX CONCURRENTLY cannot run inside a transaction, so this migration
* uses the non-transactional option supported by node-pg-migrate.
*/

/** @type {import('node-pg-migrate').MigrationBuilder} */
exports.up = async (pgm) => {
pgm.noTransaction();
pgm.sql(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_loan_events_type_created_at
ON loan_events (event_type, created_at)
`);
};

/** @type {import('node-pg-migrate').MigrationBuilder} */
exports.down = async (pgm) => {
pgm.noTransaction();
pgm.sql(`
DROP INDEX CONCURRENTLY IF EXISTS idx_loan_events_type_created_at
`);
};
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"migrate:create": "node-pg-migrate create",
"migrate:up": "node-pg-migrate up",
"migrate:down": "node-pg-migrate down",
"_migrations_note": "Single source of truth: backend/migrations/*.js (node-pg-migrate). Do NOT add .sql files to src/db/ — they are not executed by any script.",
"seed": "npm run seed:dev",
"seed:dev": "tsx src/seed/index.ts",
"seed:reset": "tsx src/seed/index.ts --reset"
Expand Down
131 changes: 131 additions & 0 deletions backend/src/__tests__/poolRouteScopes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Issue #1179: Integration tests that exercise pool write route authorization
* at the route layer (through app.use) so the requireScopes middleware is
* actually exercised.
*
* The lender role in rbac.ts only grants read:pool, NOT write:pool, so all
* pool write routes (build-deposit, build-withdraw, build-emergency-withdraw,
* submit) must return 403 for a lender JWT. A borrower JWT must also be
* rejected because borrowers lack even read:pool.
*/

import { describe, it, expect, beforeAll } from "@jest/globals";
import request from "supertest";
import jwt from "jsonwebtoken";
import app from "../app.js";

const JWT_SECRET = "test-jwt-secret-poolscopes";

function mintToken(
publicKey: string,
role: "lender" | "borrower" | "admin",
scopes: string[],
): string {
return jwt.sign({ publicKey, role, scopes }, JWT_SECRET, {
expiresIn: "1h",
algorithm: "HS256",
});
}

const LENDER_KEY = "GLENDER000000000000000000000000000000000000000000000000001";
const BORROWER_KEY = "GBORROWER0000000000000000000000000000000000000000000000001";

// lender has read:pool but NOT write:pool — as per ROLE_SCOPES in rbac.ts
const lenderToken = mintToken(LENDER_KEY, "lender", ["read:loans", "read:pool"]);
// borrower has no pool scopes at all
const borrowerToken = mintToken(BORROWER_KEY, "borrower", [
"read:loans",
"write:repayment",
"read:score",
"read:notifications",
"write:notifications",
]);

const POOL_WRITE_ROUTES: Array<{ method: "post"; path: string; body: Record<string, unknown> }> = [
{
method: "post",
path: "/api/pool/build-deposit",
body: { depositorPublicKey: LENDER_KEY, token: "GTOKEN", amount: 100 },
},
{
method: "post",
path: "/api/pool/build-withdraw",
body: { depositorPublicKey: LENDER_KEY, token: "GTOKEN", amount: 100 },
},
{
method: "post",
path: "/api/pool/build-emergency-withdraw",
body: { depositorPublicKey: LENDER_KEY, token: "GTOKEN", shares: 100 },
},
{
method: "post",
path: "/api/pool/submit",
body: { signedTxXdr: "AAAA" },
},
];

beforeAll(() => {
process.env.JWT_SECRET = JWT_SECRET;
});

describe("Pool write route authorization (#1179)", () => {
describe("lender JWT (has read:pool, missing write:pool)", () => {
for (const route of POOL_WRITE_ROUTES) {
it(`${route.method.toUpperCase()} ${route.path} → 403`, async () => {
const res = await request(app)
[route.method](route.path)
.set("Authorization", `Bearer ${lenderToken}`)
.send(route.body);

expect(res.status).toBe(403);
});
}
});

describe("borrower JWT (no pool scopes at all)", () => {
for (const route of POOL_WRITE_ROUTES) {
it(`${route.method.toUpperCase()} ${route.path} → 403`, async () => {
const res = await request(app)
[route.method](route.path)
.set("Authorization", `Bearer ${borrowerToken}`)
.send(route.body);

// borrower also fails requireLender (role check) before even reaching
// requireScopes — expect 403 either way
expect(res.status).toBe(403);
});
}
});

describe("no JWT", () => {
for (const route of POOL_WRITE_ROUTES) {
it(`${route.method.toUpperCase()} ${route.path} → 401`, async () => {
const res = await request(app)
[route.method](route.path)
.send(route.body);

expect(res.status).toBe(401);
});
}
});

describe("pool read routes are accessible with lender JWT (read:pool)", () => {
it("GET /api/pool/stats → not 403 (auth passes, may fail for other reasons)", async () => {
const res = await request(app)
.get("/api/pool/stats")
.set("Authorization", `Bearer ${lenderToken}`);

// Auth layer passes (not 401/403) — downstream may 500 without DB
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});

it("borrower JWT on GET /api/pool/stats → 403 (requireLender)", async () => {
const res = await request(app)
.get("/api/pool/stats")
.set("Authorization", `Bearer ${borrowerToken}`);

expect(res.status).toBe(403);
});
});
});

This file was deleted.

134 changes: 134 additions & 0 deletions docs/SECURITY-MODEL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# RemitLend Authentication & Authorization Model

This document describes the security model for the RemitLend backend API: how
identities are established, how roles map to scopes, and which scope guard
protects each route group. See also [SECURITY.md](../SECURITY.md) for the
vulnerability-disclosure policy.

---

## Authentication flows

### 1. Challenge–signature–JWT (primary, for wallet users)

1. **GET /api/auth/challenge?publicKey=G…** — server returns a one-time nonce
message valid for 5 minutes.
2. Client signs the message with the Stellar Ed25519 private key.
3. **POST /api/auth/verify** — server verifies the signature via
`Keypair.verify`, resolves the role for that public key (see [Role
resolution](#role-resolution) below), and mints a JWT.
4. The JWT is returned both in the JSON body and set as a `httpOnly`,
`SameSite=strict` cookie named `remitlend_jwt` (overridable via
`JWT_COOKIE_NAME` env var). The cookie is used for SSE/EventSource
connections that cannot attach `Authorization` headers.
5. JWT lifetime: **24 hours** (`JWT_EXPIRES_IN = "24h"`).
Secret: `JWT_SECRET` environment variable (required).

JWT payload shape (`JwtPayload` in `authService.ts`):

```ts
{
publicKey: string; // Stellar G… address
role: UserRole; // "admin" | "borrower" | "lender"
scopes: string[]; // derived from role via ROLE_SCOPES
iat: number;
exp: number;
}
```

Subsequent requests supply the JWT via:
- `Authorization: Bearer <token>` header, or
- the `remitlend_jwt` cookie.

### 2. API-key authentication (for backend services / admin tooling)

Admin operations use `x-api-key: <key>` instead of JWTs. Keys are configured
in the `INTERNAL_API_KEY` environment variable as a comma-separated list.

Key formats:

| Format | Example | Grants |
|---|---|---|
| Legacy (no scope prefix) | `mysecretkey` | All admin scopes |
| Scoped | `admin:disputes:mysecretkey` | Only `admin:disputes` |

Available scopes: `admin:disputes`, `admin:indexer`, `admin:webhooks`,
`admin:loans`.

Implemented in `backend/src/middleware/auth.ts` (`requireApiKey`).

---

## Role resolution

`resolveRoleForWallet(publicKey)` in `backend/src/auth/rbac.ts`:

1. If the public key is in `ADMIN_WALLETS` (comma-separated env) → **admin**.
2. If the public key is in `LENDER_WALLETS` → **lender**.
3. Otherwise → **borrower**.

---

## Role-to-scope table

Defined in `ROLE_SCOPES` in `backend/src/auth/rbac.ts`:

| Role | Scopes granted |
|---|---|
| `admin` | `admin:all` |
| `lender` | `read:loans`, `read:pool` |
| `borrower` | `read:loans`, `write:repayment`, `read:score`, `read:notifications`, `write:notifications` |

> **Note:** `lender` does **not** have `write:pool`. Pool write endpoints
> (`build-deposit`, `build-withdraw`, `build-emergency-withdraw`, `submit`)
> require `write:pool`, which means lenders currently receive 403 on those
> routes. This is a known gap tracked in issue #1179.

---

## Route-group authorization map

### JWT-authenticated routes (`requireJwtAuth` + `requireScopes`)

| Route group | Role check | Required scope |
|---|---|---|
| `GET /api/pool/stats` | `requireLender` | `read:pool` |
| `GET /api/pool/depositor/:address` | `requireLender` | `read:pool` |
| `GET /api/pool/depositor/:address/yield-history` | `requireLender` | `read:pool` |
| `GET /api/pool/:token/share-price` | `requireLender` | `read:pool` |
| `POST /api/pool/build-deposit` | `requireLender` | `write:pool` |
| `POST /api/pool/build-withdraw` | `requireLender` | `write:pool` |
| `POST /api/pool/build-emergency-withdraw` | `requireLender` | `write:pool` |
| `POST /api/pool/submit` | `requireLender` | `write:pool` |
| `GET /api/loans/*` | — | `read:loans` |
| `GET /api/indexer/loans/*` | — | `read:loans` |
| `GET/POST /api/notifications` | — | `read:notifications` / `write:notifications` |
| `POST /api/remittances` | — | `write:remittances` |
| `GET /api/remittances` | — | `read:remittances` |

### API-key-authenticated routes (`requireApiKey(scope)`)

| Route | Required scope |
|---|---|
| `GET /api/admin/loan-disputes` | `admin:disputes` |
| `POST /api/admin/loan-disputes/:id/resolve` | `admin:disputes` |
| `POST /api/admin/loans/check-defaults` | `admin:loans` |
| `GET /api/admin/indexer/*` | `admin:indexer` |
| `GET /api/events/status` | `admin:indexer` |
| `GET /api/indexer/events/recent` | `admin:indexer` |
| `GET/POST/DELETE /api/indexer/webhooks/*` | `admin:webhooks` |
| `GET/POST/DELETE /api/admin/webhooks/*` | `admin:webhooks` |

---

## Auth middleware stack

```
backend/src/middleware/jwtAuth.ts — requireJwtAuth, requireLender,
requireBorrower, requireScopes
backend/src/middleware/auth.ts — requireApiKey (API-key scoped access)
backend/src/services/authService.ts — generateJwtToken, verifyJwtToken,
generateChallenge, verifySignature
backend/src/auth/rbac.ts — ROLE_SCOPES, resolveRoleForWallet,
resolveScopesForRole
```
Loading