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
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,28 @@ SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
# DB (when added)
# DATABASE_URL=postgresql://user:pass@localhost:5432/liquifact
# REDIS_URL=redis://localhost:6379

# --------------------
# JWT Authentication |
# --------------------
# Secret used to sign and verify JSON Web Tokens.
# Must be a long, random string in production. Defaults to "test-secret" locally.
# JWT_SECRET=replace-with-a-long-random-secret

# ------------------------
# API Key Authentication |
# ------------------------
# Semicolon-separated list of API key entries, each a JSON object.
# Schema per entry:
# key (string, required) — must start with "lf_", min 10 chars
# clientId (string, required) — unique identifier for the service client
# scopes (array, required) — non-empty list from: invoices:read, invoices:write, escrow:read
# revoked (bool, optional) — set to true to disable the key without removing it
#
# Example (two entries — one active, one revoked):
# API_KEYS={"key":"lf_prod_service_a_key","clientId":"billing-service","scopes":["invoices:read","invoices:write"]};{"key":"lf_old_service_b_key","clientId":"legacy-service","scopes":["invoices:read"],"revoked":true}
#
# Key rotation: add the new key entry, deploy, then set "revoked": true on the
# old entry and redeploy. The old key is rejected immediately; the new key works
# from the first deploy.
# API_KEYS=
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,5 @@ dist
.idea/
.vscode/
*.swp

node_modules/*
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,83 @@ Unauthenticated requests are rejected with `401 Unauthorized`.

---

## API Key Authentication

In addition to JWT, the API supports **API key authentication** for trusted machine-to-machine (service-to-service) clients. API keys are scoped so that each key can only access the subset of operations it was provisioned for.

### Header

```http
X-API-Key: lf_your_api_key_here
```

### Configuration (`API_KEYS`)

API keys are configured via the `API_KEYS` environment variable — a **semicolon-separated** list of JSON objects. Each object has:

| Field | Type | Required | Description |
|------------|-----------|----------|-------------|
| `key` | `string` | ✅ | The secret key. Must start with `lf_` and be ≥ 10 characters. |
| `clientId` | `string` | ✅ | Unique identifier for the calling service. |
| `scopes` | `string[]`| ✅ | Non-empty list of permissions (see table below). |
| `revoked` | `boolean` | ❌ | When `true` the key is instantly rejected. Defaults to `false`. |

**Example value:**

```
API_KEYS={"key":"lf_billing_svc_key","clientId":"billing-service","scopes":["invoices:read","invoices:write"]};{"key":"lf_legacy_key","clientId":"legacy-svc","scopes":["invoices:read"],"revoked":true}
```

### Available Scopes

| Scope | Grants access to |
|------------------|-------------------------------------------------|
| `invoices:read` | `GET /api/invoices` — list active invoices |
| `invoices:write` | `POST /api/invoices` — create / modify invoices |
| `escrow:read` | `GET /api/escrow/:id` — read escrow state |

### Error Responses

| Status | Reason |
|--------|--------|
| `401` | Header missing, key unknown, or key revoked |
| `403` | Key is valid but lacks the required scope |

### Key Rotation

Zero-downtime key rotation flow:

1. **Add** the new key entry to `API_KEYS` alongside the existing one.
2. **Deploy** — both keys accept traffic.
3. **Update** the calling service to use the new key.
4. **Revoke** the old key by setting `"revoked": true` in its entry and redeploy.
5. *(Optional)* Remove the revoked entry entirely in a follow-up deploy.

### Usage Example

Apply the middleware to any route:

```js
const { authenticateApiKey } = require('./src/middleware/apiKeyAuth');

// No scope requirement — any valid, non-revoked key passes
app.get('/api/invoices', authenticateApiKey(), handler);

// Scope-guarded endpoint
app.post('/api/invoices', authenticateApiKey({ requiredScope: 'invoices:write' }), handler);
```

On success, `req.apiClient` is populated with:

```json
{
"clientId": "billing-service",
"scopes": ["invoices:read", "invoices:write"]
}
```

---

## Rate Limiting

| Scope | Limit |
Expand Down
36 changes: 36 additions & 0 deletions __mocks__/knex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict';

// Root-level manual mock for the 'knex' npm package.
// Applied automatically via moduleNameMapper in jest config.
// Makes the query builder thenable so `await query` resolves to [].

const makeQueryBuilder = () => {
const qb = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
first: jest.fn().mockReturnThis(),
then(resolve, reject) {
return Promise.resolve([]).then(resolve, reject);
},
catch(handler) {
return Promise.resolve([]).catch(handler);
},
finally(handler) {
return Promise.resolve([]).finally(handler);
},
};
return qb;
};

// db is the knex instance — it's callable (db('tableName') returns a query builder)
const db = jest.fn(() => makeQueryBuilder());
db.raw = jest.fn().mockResolvedValue([]);

// knex factory — called with config, returns the db instance
const knex = jest.fn(() => db);

module.exports = knex;
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ module.exports = [
},
},
{
files: ['src/**/*.test.js', 'src/__tests__/**/*.js'],
files: ['src/**/*.test.js', 'src/__tests__/**/*.js', 'src/**/__mocks__/**/*.js'],
languageOptions: {
sourceType: 'module',
globals: {
Expand Down
Loading
Loading