@joinremba/gate 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -8
- package/package.json +21 -1
- package/src/api-keys.test.ts +54 -0
- package/src/api-keys.ts +73 -3
- package/src/index.test.ts +66 -0
- package/src/index.ts +106 -6
- package/src/rate-limit.ts +10 -0
- package/src/stores/postgres-api-keys.test.ts +86 -0
- package/src/stores/postgres-api-keys.ts +107 -0
- package/src/stores/postgres.test.ts +101 -0
- package/src/stores/postgres.ts +100 -0
- package/src/stores/redis-api-keys.test.ts +51 -0
- package/src/stores/redis-api-keys.ts +79 -0
- package/src/stores/redis.test.ts +77 -0
- package/src/stores/redis.ts +60 -0
package/README.md
CHANGED
|
@@ -162,7 +162,7 @@ Customise the key function to rate-limit by user ID, API key, or IP.
|
|
|
162
162
|
|
|
163
163
|
### API Keys (`@joinremba/gate/api-keys`)
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
Validates API keys with optional scoped permissions. Designed for **internal** authentication — service-to-service, admin dashboards, cron jobs, webhooks. Not a replacement for user auth (OAuth, JWTs, password login).
|
|
166
166
|
|
|
167
167
|
```ts
|
|
168
168
|
import { createApiKeyValidator } from "@joinremba/gate/api-keys";
|
|
@@ -182,6 +182,89 @@ const result = auth(request);
|
|
|
182
182
|
if (!result.authenticated) throw new AuthenticationError(result.error);
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
+
**When to use it:** You have a few static keys for internal services, a shared webhook secret, or scoped tokens for admin tools. Keys are configured at startup and held in memory — no database query per request, zero dependencies.
|
|
186
|
+
|
|
187
|
+
**When not to use it:** You need key rotation, hashed storage, per-user API keys, expiry/revocation, or rate limiting per key. For those cases, use the hashed or DB-backed stores below.
|
|
188
|
+
|
|
189
|
+
#### Hashed API Keys
|
|
190
|
+
|
|
191
|
+
Store SHA-256 hashes instead of plaintext keys. Protects against memory dumps.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
const validator = createApiKeyValidator(
|
|
195
|
+
[{ key: "9418b81169b7...", scopes: ["admin"] }], // pre-computed sha256("sk-live-123")
|
|
196
|
+
{ hashKeys: true }
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
await validator.verify("sk-live-123");
|
|
200
|
+
// -> { authenticated: true, key: "sk-live-123", scopes: ["admin"] }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
#### DB-backed API Key Stores
|
|
204
|
+
|
|
205
|
+
Validate keys against Postgres or Redis. Keys can be added/removed at runtime.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { PostgresApiKeyStore } from "@joinremba/gate/stores/postgres-api-keys";
|
|
209
|
+
|
|
210
|
+
const store = new PostgresApiKeyStore(pgClient);
|
|
211
|
+
await store.ensureTable();
|
|
212
|
+
|
|
213
|
+
// Add a key
|
|
214
|
+
await store.setKey({ key: "sk-live-abc", scopes: ["read"] }, expiresAt);
|
|
215
|
+
|
|
216
|
+
// Validate
|
|
217
|
+
const result = await store.verify("sk-live-abc");
|
|
218
|
+
|
|
219
|
+
// Remove
|
|
220
|
+
await store.deleteKey("sk-live-abc");
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { RedisApiKeyStore } from "@joinremba/gate/stores/redis-api-keys";
|
|
225
|
+
|
|
226
|
+
const store = new RedisApiKeyStore(redisClient);
|
|
227
|
+
await store.setKey({ key: "sk-redis-key", scopes: ["admin"] });
|
|
228
|
+
const result = await store.verify("sk-redis-key");
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Combined Middleware
|
|
232
|
+
|
|
233
|
+
Run auth, rate limiting, and idempotency in a single middleware call:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const gate = createGate({
|
|
237
|
+
apiKeys: [{ key: "sk-admin" }],
|
|
238
|
+
rateLimit: { windowMs: 60_000, max: 100 },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
app.use(
|
|
242
|
+
gate.middleware({
|
|
243
|
+
auth: true,
|
|
244
|
+
requiredScopes: ["write"],
|
|
245
|
+
rateLimit: true,
|
|
246
|
+
idempotency: true,
|
|
247
|
+
excludePaths: ["/health", "/metrics"],
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The middleware returns 401 for invalid/missing keys, 429 when rate limited, and caches idempotent responses automatically.
|
|
253
|
+
|
|
254
|
+
### Per-key Rate Limiting
|
|
255
|
+
|
|
256
|
+
Rate-limit by API key instead of IP:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { rateLimit, keyByApiKey } from "@joinremba/gate/rate-limit";
|
|
260
|
+
|
|
261
|
+
const limiter = rateLimit({
|
|
262
|
+
windowMs: 60_000,
|
|
263
|
+
max: 30,
|
|
264
|
+
keyFn: keyByApiKey,
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
185
268
|
### Errors (`@joinremba/gate/errors`)
|
|
186
269
|
|
|
187
270
|
Standard error types for consistent error handling.
|
|
@@ -313,15 +396,13 @@ app.post("/orders", async (req, res, next) => {
|
|
|
313
396
|
- In-memory idempotency store
|
|
314
397
|
- In-memory rate limiting store
|
|
315
398
|
|
|
316
|
-
**V1**
|
|
399
|
+
**V1** (current)
|
|
317
400
|
|
|
318
|
-
-
|
|
319
|
-
- Redis idempotency store
|
|
320
|
-
- Postgres idempotency store
|
|
321
|
-
- Rate limiting middleware
|
|
401
|
+
- Redis and Postgres stores for idempotency and rate limiting
|
|
322
402
|
- API key hashing and validation
|
|
323
|
-
-
|
|
324
|
-
-
|
|
403
|
+
- DB-backed API key stores (Redis, Postgres)
|
|
404
|
+
- Combined middleware (auth + rate limit + idempotency)
|
|
405
|
+
- Per-key rate limiting helper
|
|
325
406
|
|
|
326
407
|
**V2**
|
|
327
408
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinremba/gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "API safety layer for TypeScript backends. Validate requests, format responses, prevent duplicates, manage API keys, and protect endpoints from abuse.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -40,6 +40,26 @@
|
|
|
40
40
|
"types": "./src/errors.ts",
|
|
41
41
|
"import": "./src/errors.ts",
|
|
42
42
|
"default": "./src/errors.ts"
|
|
43
|
+
},
|
|
44
|
+
"./stores/redis": {
|
|
45
|
+
"types": "./src/stores/redis.ts",
|
|
46
|
+
"import": "./src/stores/redis.ts",
|
|
47
|
+
"default": "./src/stores/redis.ts"
|
|
48
|
+
},
|
|
49
|
+
"./stores/redis-api-keys": {
|
|
50
|
+
"types": "./src/stores/redis-api-keys.ts",
|
|
51
|
+
"import": "./src/stores/redis-api-keys.ts",
|
|
52
|
+
"default": "./src/stores/redis-api-keys.ts"
|
|
53
|
+
},
|
|
54
|
+
"./stores/postgres": {
|
|
55
|
+
"types": "./src/stores/postgres.ts",
|
|
56
|
+
"import": "./src/stores/postgres.ts",
|
|
57
|
+
"default": "./src/stores/postgres.ts"
|
|
58
|
+
},
|
|
59
|
+
"./stores/postgres-api-keys": {
|
|
60
|
+
"types": "./src/stores/postgres-api-keys.ts",
|
|
61
|
+
"import": "./src/stores/postgres-api-keys.ts",
|
|
62
|
+
"default": "./src/stores/postgres-api-keys.ts"
|
|
43
63
|
}
|
|
44
64
|
},
|
|
45
65
|
"files": [
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createApiKeyValidator } from "./api-keys";
|
|
3
|
+
import type { AuthenticateResult } from "./api-keys";
|
|
4
|
+
|
|
5
|
+
test("validate returns authenticated for matching key", () => {
|
|
6
|
+
const validator = createApiKeyValidator([{ key: "sk-live-abc", scopes: ["read"] }]);
|
|
7
|
+
const result = validator.validate("sk-live-abc");
|
|
8
|
+
expect(result.authenticated).toBe(true);
|
|
9
|
+
expect(result.scopes).toEqual(["read"]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("validate returns error for unknown key", () => {
|
|
13
|
+
const validator = createApiKeyValidator([{ key: "sk-live-abc" }]);
|
|
14
|
+
const result = validator.validate("sk-live-xyz");
|
|
15
|
+
expect(result.authenticated).toBe(false);
|
|
16
|
+
expect(result.error).toBe("Invalid API key");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("verify with hashKeys true hashes before lookup", async () => {
|
|
20
|
+
const validator = createApiKeyValidator(
|
|
21
|
+
[
|
|
22
|
+
{
|
|
23
|
+
key: "9418b81169b79003fd8c4481e61b79a762e996a0c172cda188c927714b5ee05b",
|
|
24
|
+
scopes: ["admin"],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
{ hashKeys: true }
|
|
28
|
+
);
|
|
29
|
+
// "sk-live-123" sha256 = 9418b81169b7...
|
|
30
|
+
const result = await validator.verify("sk-live-123");
|
|
31
|
+
expect(result.authenticated).toBe(true);
|
|
32
|
+
expect(result.scopes).toEqual(["admin"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("authenticate middleware reads Authorization header", async () => {
|
|
36
|
+
const validator = createApiKeyValidator([{ key: "sk-test", scopes: ["read"] }]);
|
|
37
|
+
const auth = validator.authenticate({ requiredScopes: ["read"] });
|
|
38
|
+
const req = new Request("http://localhost", {
|
|
39
|
+
headers: { Authorization: "Bearer sk-test" },
|
|
40
|
+
});
|
|
41
|
+
const result = await (auth(req) as Promise<AuthenticateResult>);
|
|
42
|
+
expect(result.authenticated).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("authenticate rejects missing scopes", async () => {
|
|
46
|
+
const validator = createApiKeyValidator([{ key: "sk-test", scopes: ["read"] }]);
|
|
47
|
+
const auth = validator.authenticate({ requiredScopes: ["write"] });
|
|
48
|
+
const req = new Request("http://localhost", {
|
|
49
|
+
headers: { Authorization: "Bearer sk-test" },
|
|
50
|
+
});
|
|
51
|
+
const result = await (auth(req) as Promise<AuthenticateResult>);
|
|
52
|
+
expect(result.authenticated).toBe(false);
|
|
53
|
+
expect(result.error).toBe("Insufficient permissions");
|
|
54
|
+
});
|
package/src/api-keys.ts
CHANGED
|
@@ -4,6 +4,10 @@ export interface ApiKeyEntry {
|
|
|
4
4
|
metadata?: Record<string, unknown>;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
export interface ApiKeyValidatorOptions {
|
|
8
|
+
hashKeys?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
export interface AuthenticateOptions {
|
|
8
12
|
requiredScopes?: string[];
|
|
9
13
|
header?: string;
|
|
@@ -13,11 +17,32 @@ export interface AuthenticateResult {
|
|
|
13
17
|
authenticated: boolean;
|
|
14
18
|
key?: string;
|
|
15
19
|
scopes?: string[];
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
16
21
|
error?: string;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
const
|
|
24
|
+
async function sha256(input: string): Promise<string> {
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const data = encoder.encode(input);
|
|
27
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
28
|
+
return Array.from(new Uint8Array(hash))
|
|
29
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
30
|
+
.join("");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createApiKeyValidator(keys: ApiKeyEntry[], options: ApiKeyValidatorOptions = {}) {
|
|
34
|
+
const { hashKeys = false } = options;
|
|
35
|
+
const keyMap = new Map<
|
|
36
|
+
string,
|
|
37
|
+
{ key: string; scopes?: string[]; metadata?: Record<string, unknown> }
|
|
38
|
+
>();
|
|
39
|
+
|
|
40
|
+
for (const entry of keys) {
|
|
41
|
+
keyMap.set(entry.key, { key: entry.key, scopes: entry.scopes, metadata: entry.metadata });
|
|
42
|
+
if (hashKeys) {
|
|
43
|
+
// Pre-compute hashes for lookup
|
|
44
|
+
}
|
|
45
|
+
}
|
|
21
46
|
|
|
22
47
|
return {
|
|
23
48
|
validate(providedKey: string): AuthenticateResult {
|
|
@@ -25,13 +50,58 @@ export function createApiKeyValidator(keys: ApiKeyEntry[]) {
|
|
|
25
50
|
if (!entry) {
|
|
26
51
|
return { authenticated: false, error: "Invalid API key" };
|
|
27
52
|
}
|
|
28
|
-
return {
|
|
53
|
+
return {
|
|
54
|
+
authenticated: true,
|
|
55
|
+
key: entry.key,
|
|
56
|
+
scopes: entry.scopes,
|
|
57
|
+
metadata: entry.metadata,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async verify(providedKey: string): Promise<AuthenticateResult> {
|
|
62
|
+
if (!hashKeys) {
|
|
63
|
+
return this.validate(providedKey);
|
|
64
|
+
}
|
|
65
|
+
const keyHash = await sha256(providedKey);
|
|
66
|
+
const entry = keyMap.get(keyHash);
|
|
67
|
+
if (!entry) {
|
|
68
|
+
return { authenticated: false, error: "Invalid API key" };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
authenticated: true,
|
|
72
|
+
key: providedKey,
|
|
73
|
+
scopes: entry.scopes,
|
|
74
|
+
metadata: entry.metadata,
|
|
75
|
+
};
|
|
29
76
|
},
|
|
30
77
|
|
|
31
78
|
authenticate(options: AuthenticateOptions = {}) {
|
|
32
79
|
const header = options.header ?? "Authorization";
|
|
33
80
|
const requiredScopes = options.requiredScopes ?? [];
|
|
34
81
|
|
|
82
|
+
if (hashKeys) {
|
|
83
|
+
return async (req: Request): Promise<AuthenticateResult> => {
|
|
84
|
+
const authHeader = req.headers.get(header);
|
|
85
|
+
if (!authHeader) {
|
|
86
|
+
return { authenticated: false, error: "Missing API key" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
90
|
+
const result = await this.verify(token);
|
|
91
|
+
|
|
92
|
+
if (!result.authenticated) return result;
|
|
93
|
+
|
|
94
|
+
if (requiredScopes.length > 0) {
|
|
95
|
+
const hasScopes = requiredScopes.every((s) => result.scopes?.includes(s));
|
|
96
|
+
if (!hasScopes) {
|
|
97
|
+
return { authenticated: false, error: "Insufficient permissions" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
35
105
|
return (req: Request): AuthenticateResult => {
|
|
36
106
|
const authHeader = req.headers.get(header);
|
|
37
107
|
if (!authHeader) {
|
package/src/index.test.ts
CHANGED
|
@@ -114,3 +114,69 @@ describe("api keys", () => {
|
|
|
114
114
|
expect(result.authenticated).toBe(false);
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
describe("keyByApiKey", () => {
|
|
119
|
+
test("uses API key from Authorization header", async () => {
|
|
120
|
+
const { keyByApiKey } = await import("./rate-limit");
|
|
121
|
+
const req = new Request("http://localhost", {
|
|
122
|
+
headers: { Authorization: "Bearer sk-live-abc123" },
|
|
123
|
+
});
|
|
124
|
+
const key = keyByApiKey(req);
|
|
125
|
+
expect(key).toStartWith("ak:sk-live-abc1");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("falls back to IP when no auth header", async () => {
|
|
129
|
+
const { keyByApiKey } = await import("./rate-limit");
|
|
130
|
+
const req = new Request("http://localhost");
|
|
131
|
+
const key = keyByApiKey(req);
|
|
132
|
+
expect(typeof key).toBe("string");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("middleware", () => {
|
|
137
|
+
test("passes through when no features enabled", async () => {
|
|
138
|
+
const gate = createGate();
|
|
139
|
+
const mw = gate.middleware();
|
|
140
|
+
const req = new Request("http://localhost/api");
|
|
141
|
+
let called = false;
|
|
142
|
+
const res = await mw(req, async () => {
|
|
143
|
+
called = true;
|
|
144
|
+
return new Response("ok");
|
|
145
|
+
});
|
|
146
|
+
expect(called).toBe(true);
|
|
147
|
+
expect(res).toBeInstanceOf(Response);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("rejects request when auth fails", async () => {
|
|
151
|
+
const gate = createGate({ apiKeys: [{ key: "sk-valid" }] });
|
|
152
|
+
const mw = gate.middleware({ auth: true });
|
|
153
|
+
const req = new Request("http://localhost/api", {
|
|
154
|
+
headers: { Authorization: "Bearer sk-wrong" },
|
|
155
|
+
});
|
|
156
|
+
const res = await mw(req, async () => new Response("ok"));
|
|
157
|
+
expect(res!.status).toBe(401);
|
|
158
|
+
const body = (await res!.json()) as Record<string, unknown>;
|
|
159
|
+
expect(body.success).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("rejects when rate limit exceeded", async () => {
|
|
163
|
+
const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
|
|
164
|
+
const mw = gate.middleware({ rateLimit: true });
|
|
165
|
+
const req = new Request("http://localhost/api");
|
|
166
|
+
const res = await mw(req, async () => new Response("ok"));
|
|
167
|
+
expect(res!.status).toBe(429);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("skips excluded paths", async () => {
|
|
171
|
+
const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
|
|
172
|
+
const mw = gate.middleware({ rateLimit: true, excludePaths: ["/health"] });
|
|
173
|
+
const req = new Request("http://localhost/health");
|
|
174
|
+
let called = false;
|
|
175
|
+
const res = await mw(req, async () => {
|
|
176
|
+
called = true;
|
|
177
|
+
return new Response("ok");
|
|
178
|
+
});
|
|
179
|
+
expect(called).toBe(true);
|
|
180
|
+
expect(res!.status).toBe(200);
|
|
181
|
+
});
|
|
182
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { validateRequest } from "./validate";
|
|
2
2
|
import { ok, fail, paginated, problem } from "./respond";
|
|
3
3
|
import { idempotency, InMemoryStore } from "./idempotency";
|
|
4
|
-
import { rateLimit, InMemoryRateLimitStore } from "./rate-limit";
|
|
4
|
+
import { rateLimit, InMemoryRateLimitStore, keyByApiKey } from "./rate-limit";
|
|
5
5
|
import { createApiKeyValidator } from "./api-keys";
|
|
6
|
-
import type { ApiKeyEntry } from "./api-keys";
|
|
6
|
+
import type { ApiKeyEntry, AuthenticateResult } from "./api-keys";
|
|
7
7
|
import type { IdempotencyStore } from "./idempotency";
|
|
8
8
|
import {
|
|
9
9
|
GateError,
|
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
InMemoryStore,
|
|
24
24
|
rateLimit,
|
|
25
25
|
InMemoryRateLimitStore,
|
|
26
|
+
keyByApiKey,
|
|
26
27
|
createApiKeyValidator,
|
|
27
28
|
GateError,
|
|
28
29
|
ValidationError,
|
|
@@ -52,10 +53,22 @@ export type {
|
|
|
52
53
|
AuthenticateOptions,
|
|
53
54
|
AuthenticateResult,
|
|
54
55
|
ApiKeyValidator,
|
|
56
|
+
ApiKeyValidatorOptions,
|
|
55
57
|
} from "./api-keys";
|
|
56
58
|
|
|
57
59
|
export type Middleware = (req: Request, next?: () => Promise<Response>) => Promise<Response | null>;
|
|
58
60
|
|
|
61
|
+
export interface MiddlewareOptions {
|
|
62
|
+
auth?: boolean;
|
|
63
|
+
requiredScopes?: string[];
|
|
64
|
+
rateLimit?: boolean;
|
|
65
|
+
idempotency?: boolean;
|
|
66
|
+
/** Override the max for this specific middleware. */
|
|
67
|
+
rateLimitMax?: number;
|
|
68
|
+
/** Paths to skip entirely. */
|
|
69
|
+
excludePaths?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
export interface GateOptions {
|
|
60
73
|
apiKeys?: ApiKeyEntry[];
|
|
61
74
|
idempotency?: {
|
|
@@ -66,10 +79,19 @@ export interface GateOptions {
|
|
|
66
79
|
rateLimit?: {
|
|
67
80
|
windowMs?: number;
|
|
68
81
|
max?: number;
|
|
69
|
-
|
|
82
|
+
store?: IdempotencyStore;
|
|
83
|
+
keyFn?: (req: Request) => string;
|
|
70
84
|
};
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
export type MiddlewareResult =
|
|
88
|
+
| {
|
|
89
|
+
passed: true;
|
|
90
|
+
auth?: { key: string; scopes?: string[] };
|
|
91
|
+
rateLimit?: { remaining: number; reset: number };
|
|
92
|
+
}
|
|
93
|
+
| { passed: false; status: number; body: unknown };
|
|
94
|
+
|
|
73
95
|
export interface Gate {
|
|
74
96
|
validate: typeof validateRequest;
|
|
75
97
|
ok: typeof ok;
|
|
@@ -79,7 +101,7 @@ export interface Gate {
|
|
|
79
101
|
idempotency: ReturnType<typeof idempotency>;
|
|
80
102
|
rateLimit: ReturnType<typeof rateLimit>;
|
|
81
103
|
apiKeys: ReturnType<typeof createApiKeyValidator>;
|
|
82
|
-
middleware(): Middleware;
|
|
104
|
+
middleware(opts?: MiddlewareOptions): Middleware;
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
export function createGate(options: GateOptions = {}): Gate {
|
|
@@ -96,6 +118,8 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
96
118
|
|
|
97
119
|
const apiKeyValidator = createApiKeyValidator(options.apiKeys ?? []);
|
|
98
120
|
|
|
121
|
+
const defaultFail = (message: string, code?: string) => fail(message, code ?? "UNAUTHORIZED");
|
|
122
|
+
|
|
99
123
|
const gate: Gate = {
|
|
100
124
|
validate: validateRequest,
|
|
101
125
|
ok,
|
|
@@ -106,10 +130,86 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
106
130
|
rateLimit: rlInstance,
|
|
107
131
|
apiKeys: apiKeyValidator,
|
|
108
132
|
|
|
109
|
-
middleware() {
|
|
133
|
+
middleware(opts?: MiddlewareOptions) {
|
|
134
|
+
const {
|
|
135
|
+
auth = options.apiKeys != null && options.apiKeys.length > 0,
|
|
136
|
+
requiredScopes,
|
|
137
|
+
rateLimit: enableRl = options.rateLimit != null,
|
|
138
|
+
idempotency: enableIdem = false,
|
|
139
|
+
excludePaths = [],
|
|
140
|
+
} = opts ?? {};
|
|
141
|
+
|
|
110
142
|
return async (req: Request, next?: () => Promise<Response>) => {
|
|
111
143
|
if (!next) return null;
|
|
112
|
-
|
|
144
|
+
|
|
145
|
+
const path = new URL(req.url).pathname;
|
|
146
|
+
if (excludePaths.some((p) => path === p || path.startsWith(p))) {
|
|
147
|
+
return next();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Rate limit check
|
|
151
|
+
if (enableRl) {
|
|
152
|
+
const rlResult = await rlInstance.check(req);
|
|
153
|
+
if (!rlResult.allowed) {
|
|
154
|
+
const body = defaultFail("Too many requests", "RATE_LIMIT_EXCEEDED");
|
|
155
|
+
return new Response(JSON.stringify(body), {
|
|
156
|
+
status: 429,
|
|
157
|
+
headers: {
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
"Retry-After": String(Math.ceil((rlResult.reset - Date.now()) / 1000)),
|
|
160
|
+
"X-RateLimit-Remaining": "0",
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
req.headers.set("X-RateLimit-Remaining", String(rlResult.remaining));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Auth check
|
|
168
|
+
if (auth) {
|
|
169
|
+
const authFn = apiKeyValidator.authenticate({ requiredScopes });
|
|
170
|
+
const authResult = await (authFn(req) as
|
|
171
|
+
| Promise<AuthenticateResult>
|
|
172
|
+
| AuthenticateResult);
|
|
173
|
+
if (!authResult.authenticated) {
|
|
174
|
+
const body = defaultFail(authResult.error ?? "Unauthorized", "AUTHENTICATION_ERROR");
|
|
175
|
+
return new Response(JSON.stringify(body), {
|
|
176
|
+
status: 401,
|
|
177
|
+
headers: { "Content-Type": "application/json" },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
(req as unknown as Record<string, unknown>).gateAuth = authResult;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Idempotency check
|
|
184
|
+
if (enableIdem) {
|
|
185
|
+
const idemKey = req.headers.get(idempInstance.keyHeader);
|
|
186
|
+
if (idemKey) {
|
|
187
|
+
const cached = await idempInstance.getResponse(idemKey);
|
|
188
|
+
if (cached) {
|
|
189
|
+
return new Response(JSON.stringify(cached), {
|
|
190
|
+
status: 200,
|
|
191
|
+
headers: { "Content-Type": "application/json" },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
(req as unknown as Record<string, unknown>).gateIdempotencyKey = idemKey;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const response = await next();
|
|
199
|
+
if (!response) return null;
|
|
200
|
+
|
|
201
|
+
// Store response for idempotency
|
|
202
|
+
if (enableIdem) {
|
|
203
|
+
const idemKey = (req as unknown as Record<string, unknown>).gateIdempotencyKey as
|
|
204
|
+
| string
|
|
205
|
+
| undefined;
|
|
206
|
+
if (idemKey && response.status < 500) {
|
|
207
|
+
const body = await response.clone().json();
|
|
208
|
+
await idempInstance.setResponse(idemKey, body);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return response;
|
|
113
213
|
};
|
|
114
214
|
},
|
|
115
215
|
};
|
package/src/rate-limit.ts
CHANGED
|
@@ -35,6 +35,16 @@ export interface RateLimitOptions {
|
|
|
35
35
|
keyFn?: (req: Request) => string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Rate-limit by API key (Bearer token from Authorization header). Falls back to IP. */
|
|
39
|
+
export function keyByApiKey(req: Request): string {
|
|
40
|
+
const auth = req.headers.get("authorization");
|
|
41
|
+
if (auth) {
|
|
42
|
+
const token = auth.replace(/^Bearer\s+/i, "").trim();
|
|
43
|
+
if (token) return `ak:${token.slice(0, 12)}`;
|
|
44
|
+
}
|
|
45
|
+
return req.headers.get("x-forwarded-for") ?? "global";
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
export function rateLimit(options: RateLimitOptions = {}) {
|
|
39
49
|
const windowMs = options.windowMs ?? 60_000;
|
|
40
50
|
const max = options.max ?? 100;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { PostgresApiKeyStore, type PostgresClient } from "./postgres-api-keys";
|
|
3
|
+
|
|
4
|
+
function mockPostgresClient(): PostgresClient {
|
|
5
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {};
|
|
6
|
+
return {
|
|
7
|
+
async query(sql: string, params?: unknown[]) {
|
|
8
|
+
const tableMatch = sql.match(/(?:FROM|INTO|UPDATE|TABLE)\s+(\w+)/i);
|
|
9
|
+
const tableName = tableMatch?.[1] ?? "unknown";
|
|
10
|
+
if (!tables[tableName]) tables[tableName] = new Map();
|
|
11
|
+
const table = tables[tableName];
|
|
12
|
+
|
|
13
|
+
if (sql.includes("CREATE TABLE IF NOT EXISTS")) {
|
|
14
|
+
return { rows: [] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (sql.includes("DELETE")) {
|
|
18
|
+
if (params?.[0]) table.delete(params[0] as string);
|
|
19
|
+
return { rows: [] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (sql.includes("SELECT") && sql.includes("WHERE key_hash = $1")) {
|
|
23
|
+
const keyHash = params?.[0] as string;
|
|
24
|
+
const entry = table.get(keyHash);
|
|
25
|
+
if (!entry) return { rows: [] };
|
|
26
|
+
return { rows: [entry] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (sql.includes("INSERT") && sql.includes("ON CONFLICT")) {
|
|
30
|
+
const keyHash = params?.[0] as string;
|
|
31
|
+
table.set(keyHash, {
|
|
32
|
+
key_hash: keyHash,
|
|
33
|
+
scopes: params?.[1],
|
|
34
|
+
metadata: params?.[2],
|
|
35
|
+
expires_at: params?.[3],
|
|
36
|
+
});
|
|
37
|
+
return { rows: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { rows: [] };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("PostgresApiKeyStore validate and setKey", async () => {
|
|
46
|
+
const client = mockPostgresClient();
|
|
47
|
+
const store = new PostgresApiKeyStore(client);
|
|
48
|
+
await store.ensureTable();
|
|
49
|
+
await store.setKey({ key: "sk-live-test", scopes: ["admin"] });
|
|
50
|
+
|
|
51
|
+
const result = await store.validate("sk-live-test");
|
|
52
|
+
expect(result.authenticated).toBe(true);
|
|
53
|
+
expect(result.scopes).toEqual(["admin"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("PostgresApiKeyStore rejects unknown key", async () => {
|
|
57
|
+
const client = mockPostgresClient();
|
|
58
|
+
const store = new PostgresApiKeyStore(client);
|
|
59
|
+
await store.ensureTable();
|
|
60
|
+
const result = await store.validate("sk-unknown");
|
|
61
|
+
expect(result.authenticated).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("PostgresApiKeyStore rejects expired key", async () => {
|
|
65
|
+
const client = mockPostgresClient();
|
|
66
|
+
const store = new PostgresApiKeyStore(client);
|
|
67
|
+
await store.ensureTable();
|
|
68
|
+
await store.setKey({ key: "sk-expired" }, Date.now() - 1000);
|
|
69
|
+
const result = await store.validate("sk-expired");
|
|
70
|
+
expect(result.authenticated).toBe(false);
|
|
71
|
+
expect(result.error).toBe("API key expired");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("PostgresApiKeyStore authenticate middleware", async () => {
|
|
75
|
+
const client = mockPostgresClient();
|
|
76
|
+
const store = new PostgresApiKeyStore(client);
|
|
77
|
+
await store.ensureTable();
|
|
78
|
+
await store.setKey({ key: "sk-auth-test", scopes: ["read"] });
|
|
79
|
+
|
|
80
|
+
const auth = store.authenticate({ requiredScopes: ["read"] });
|
|
81
|
+
const req = new Request("http://localhost", {
|
|
82
|
+
headers: { Authorization: "Bearer sk-auth-test" },
|
|
83
|
+
});
|
|
84
|
+
const result = await auth(req);
|
|
85
|
+
expect(result.authenticated).toBe(true);
|
|
86
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ApiKeyEntry, AuthenticateResult } from "../api-keys";
|
|
2
|
+
|
|
3
|
+
export interface PostgresClient {
|
|
4
|
+
query(sql: string, params?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const TABLE_NAME = "gate_api_keys";
|
|
8
|
+
|
|
9
|
+
export class PostgresApiKeyStore {
|
|
10
|
+
constructor(
|
|
11
|
+
private client: PostgresClient,
|
|
12
|
+
private tableName: string = TABLE_NAME
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
async ensureTable(): Promise<void> {
|
|
16
|
+
await this.client.query(`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
18
|
+
key_hash TEXT PRIMARY KEY,
|
|
19
|
+
scopes TEXT,
|
|
20
|
+
metadata TEXT,
|
|
21
|
+
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000),
|
|
22
|
+
expires_at BIGINT
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async validate(providedKey: string): Promise<AuthenticateResult> {
|
|
28
|
+
const keyHash = await sha256(providedKey);
|
|
29
|
+
const { rows } = await this.client.query(
|
|
30
|
+
`SELECT key_hash, scopes, metadata, expires_at FROM ${this.tableName} WHERE key_hash = $1`,
|
|
31
|
+
[keyHash]
|
|
32
|
+
);
|
|
33
|
+
const row = rows[0];
|
|
34
|
+
if (!row) {
|
|
35
|
+
return { authenticated: false, error: "Invalid API key" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const expiresAt = row.expires_at as number | null;
|
|
39
|
+
if (expiresAt && Date.now() > expiresAt) {
|
|
40
|
+
return { authenticated: false, error: "API key expired" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const scopes = row.scopes ? (JSON.parse(row.scopes as string) as string[]) : undefined;
|
|
44
|
+
const metadata = row.metadata
|
|
45
|
+
? (JSON.parse(row.metadata as string) as Record<string, unknown>)
|
|
46
|
+
: undefined;
|
|
47
|
+
return { authenticated: true, key: providedKey, scopes, metadata };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
verify = this.validate;
|
|
51
|
+
|
|
52
|
+
authenticate(options: { requiredScopes?: string[]; header?: string } = {}) {
|
|
53
|
+
const header = options.header ?? "Authorization";
|
|
54
|
+
const requiredScopes = options.requiredScopes ?? [];
|
|
55
|
+
|
|
56
|
+
return async (req: Request): Promise<AuthenticateResult> => {
|
|
57
|
+
const authHeader = req.headers.get(header);
|
|
58
|
+
if (!authHeader) {
|
|
59
|
+
return { authenticated: false, error: "Missing API key" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
63
|
+
const result = await this.verify(token);
|
|
64
|
+
|
|
65
|
+
if (!result.authenticated) return result;
|
|
66
|
+
|
|
67
|
+
if (requiredScopes.length > 0) {
|
|
68
|
+
const hasScopes = requiredScopes.every((s) => result.scopes?.includes(s));
|
|
69
|
+
if (!hasScopes) {
|
|
70
|
+
return { authenticated: false, error: "Insufficient permissions" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async setKey(entry: ApiKeyEntry, expiresAt?: number): Promise<void> {
|
|
79
|
+
const keyHash = await sha256(entry.key);
|
|
80
|
+
await this.client.query(
|
|
81
|
+
`INSERT INTO ${this.tableName} (key_hash, scopes, metadata, expires_at)
|
|
82
|
+
VALUES ($1, $2, $3, $4)
|
|
83
|
+
ON CONFLICT (key_hash) DO UPDATE
|
|
84
|
+
SET scopes = $2, metadata = $3, expires_at = $4`,
|
|
85
|
+
[
|
|
86
|
+
keyHash,
|
|
87
|
+
entry.scopes ? JSON.stringify(entry.scopes) : null,
|
|
88
|
+
entry.metadata ? JSON.stringify(entry.metadata) : null,
|
|
89
|
+
expiresAt ?? null,
|
|
90
|
+
]
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async deleteKey(key: string): Promise<void> {
|
|
95
|
+
const keyHash = await sha256(key);
|
|
96
|
+
await this.client.query(`DELETE FROM ${this.tableName} WHERE key_hash = $1`, [keyHash]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function sha256(input: string): Promise<string> {
|
|
101
|
+
const encoder = new TextEncoder();
|
|
102
|
+
const data = encoder.encode(input);
|
|
103
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
104
|
+
return Array.from(new Uint8Array(hash))
|
|
105
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
106
|
+
.join("");
|
|
107
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { PostgresIdempotencyStore, PostgresRateLimitStore, type PostgresClient } from "./postgres";
|
|
3
|
+
|
|
4
|
+
function mockPostgresClient(): PostgresClient {
|
|
5
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {};
|
|
6
|
+
return {
|
|
7
|
+
async query(sql: string, params?: unknown[]) {
|
|
8
|
+
// rudimentary parser for our specific SQL patterns
|
|
9
|
+
const tableMatch = sql.match(/(?:FROM|INTO|UPDATE)\s+(\w+)/i);
|
|
10
|
+
const tableName = tableMatch?.[1] ?? "unknown";
|
|
11
|
+
if (!tables[tableName]) tables[tableName] = new Map();
|
|
12
|
+
|
|
13
|
+
const table = tables[tableName];
|
|
14
|
+
|
|
15
|
+
if (sql.includes("CREATE TABLE IF NOT EXISTS")) {
|
|
16
|
+
return { rows: [] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (sql.includes("DELETE")) {
|
|
20
|
+
const keyIdx = sql.indexOf("$1");
|
|
21
|
+
if (keyIdx !== -1 && params?.[0]) {
|
|
22
|
+
table.delete(params[0] as string);
|
|
23
|
+
}
|
|
24
|
+
return { rows: [] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (sql.includes("SELECT") && sql.includes("WHERE key = $1")) {
|
|
28
|
+
const key = params?.[0] as string;
|
|
29
|
+
const now = (params?.[1] as number) ?? Date.now();
|
|
30
|
+
const entry = table.get(key);
|
|
31
|
+
if (!entry || (entry.expires_at as number) <= now) {
|
|
32
|
+
return { rows: [] };
|
|
33
|
+
}
|
|
34
|
+
return { rows: [entry] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (sql.includes("INSERT") && sql.includes("ON CONFLICT") && !sql.includes("RETURNING")) {
|
|
38
|
+
const key = params?.[0] as string;
|
|
39
|
+
const value = params?.[1] as string;
|
|
40
|
+
const expiresAt = params?.[2] as number;
|
|
41
|
+
table.set(key, { key, value, expires_at: expiresAt });
|
|
42
|
+
return { rows: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (sql.includes("INSERT") && sql.includes("ON CONFLICT") && sql.includes("RETURNING")) {
|
|
46
|
+
const key = params?.[0] as string;
|
|
47
|
+
const reset = params?.[1] as number;
|
|
48
|
+
const now = (params?.[2] as number) ?? Date.now();
|
|
49
|
+
const existing = table.get(key);
|
|
50
|
+
|
|
51
|
+
if (existing && (existing.reset_at as number) > now) {
|
|
52
|
+
existing.count = (existing.count as number) + 1;
|
|
53
|
+
return { rows: [{ count: existing.count, reset_at: existing.reset_at }] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
table.set(key, { key, count: 1, reset_at: reset });
|
|
57
|
+
return { rows: [{ count: 1, reset_at: reset }] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { rows: [] };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("PostgresIdempotencyStore set and get", async () => {
|
|
66
|
+
const client = mockPostgresClient();
|
|
67
|
+
const store = new PostgresIdempotencyStore(client);
|
|
68
|
+
await store.ensureTable();
|
|
69
|
+
await store.set("payment:99", { id: "99", amount: 50 }, 60_000);
|
|
70
|
+
const result = await store.get("payment:99");
|
|
71
|
+
expect(result).toEqual({ id: "99", amount: 50 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("PostgresIdempotencyStore returns null for expired key", async () => {
|
|
75
|
+
const client = mockPostgresClient();
|
|
76
|
+
const store = new PostgresIdempotencyStore(client);
|
|
77
|
+
await store.ensureTable();
|
|
78
|
+
await store.set("expired-key", "value", -1);
|
|
79
|
+
const result = await store.get("expired-key");
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("PostgresRateLimitStore increment", async () => {
|
|
84
|
+
const client = mockPostgresClient();
|
|
85
|
+
const store = new PostgresRateLimitStore(client);
|
|
86
|
+
await store.ensureTable();
|
|
87
|
+
const first = await store.increment("ip:1.2.3.4", 60_000);
|
|
88
|
+
expect(first.count).toBe(1);
|
|
89
|
+
const second = await store.increment("ip:1.2.3.4", 60_000);
|
|
90
|
+
expect(second.count).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("PostgresRateLimitStore reset", async () => {
|
|
94
|
+
const client = mockPostgresClient();
|
|
95
|
+
const store = new PostgresRateLimitStore(client);
|
|
96
|
+
await store.ensureTable();
|
|
97
|
+
await store.increment("ip:1.2.3.4", 60_000);
|
|
98
|
+
await store.reset("ip:1.2.3.4");
|
|
99
|
+
const result = await store.increment("ip:1.2.3.4", 60_000);
|
|
100
|
+
expect(result.count).toBe(1);
|
|
101
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { IdempotencyStore } from "../idempotency";
|
|
2
|
+
import type { RateLimitStore } from "../rate-limit";
|
|
3
|
+
|
|
4
|
+
export interface PostgresClient {
|
|
5
|
+
query(sql: string, params?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const IDEMPOTENCY_TABLE = "gate_idempotency";
|
|
9
|
+
const RATE_LIMIT_TABLE = "gate_rate_limits";
|
|
10
|
+
|
|
11
|
+
export class PostgresIdempotencyStore implements IdempotencyStore {
|
|
12
|
+
constructor(
|
|
13
|
+
private client: PostgresClient,
|
|
14
|
+
private tableName: string = IDEMPOTENCY_TABLE
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
async ensureTable(): Promise<void> {
|
|
18
|
+
await this.client.query(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
20
|
+
key TEXT PRIMARY KEY,
|
|
21
|
+
value TEXT NOT NULL,
|
|
22
|
+
expires_at BIGINT NOT NULL
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async get(key: string): Promise<unknown | null> {
|
|
28
|
+
const { rows } = await this.client.query(
|
|
29
|
+
`SELECT value, expires_at FROM ${this.tableName} WHERE key = $1 AND expires_at > $2`,
|
|
30
|
+
[key, Date.now()]
|
|
31
|
+
);
|
|
32
|
+
const row = rows[0];
|
|
33
|
+
if (!row) return null;
|
|
34
|
+
const val = row.value as string;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(val) as unknown;
|
|
37
|
+
} catch {
|
|
38
|
+
return val;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async set(key: string, value: unknown, ttl: number): Promise<void> {
|
|
43
|
+
const serialized = typeof value === "string" ? value : JSON.stringify(value);
|
|
44
|
+
await this.client.query(
|
|
45
|
+
`INSERT INTO ${this.tableName} (key, value, expires_at)
|
|
46
|
+
VALUES ($1, $2, $3)
|
|
47
|
+
ON CONFLICT (key) DO UPDATE SET value = $2, expires_at = $3`,
|
|
48
|
+
[key, serialized, Date.now() + ttl]
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async delete(key: string): Promise<void> {
|
|
53
|
+
await this.client.query(`DELETE FROM ${this.tableName} WHERE key = $1`, [key]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class PostgresRateLimitStore implements RateLimitStore {
|
|
58
|
+
constructor(
|
|
59
|
+
private client: PostgresClient,
|
|
60
|
+
private tableName: string = RATE_LIMIT_TABLE
|
|
61
|
+
) {}
|
|
62
|
+
|
|
63
|
+
async ensureTable(): Promise<void> {
|
|
64
|
+
await this.client.query(`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
66
|
+
key TEXT PRIMARY KEY,
|
|
67
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
68
|
+
reset_at BIGINT NOT NULL
|
|
69
|
+
)
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const reset = now + windowMs;
|
|
76
|
+
|
|
77
|
+
const { rows } = await this.client.query(
|
|
78
|
+
`INSERT INTO ${this.tableName} (key, count, reset_at)
|
|
79
|
+
VALUES ($1, 1, $2)
|
|
80
|
+
ON CONFLICT (key) DO UPDATE
|
|
81
|
+
SET count = CASE
|
|
82
|
+
WHEN ${this.tableName}.reset_at <= $3 THEN 1
|
|
83
|
+
ELSE ${this.tableName}.count + 1
|
|
84
|
+
END,
|
|
85
|
+
reset_at = CASE
|
|
86
|
+
WHEN ${this.tableName}.reset_at <= $3 THEN $4
|
|
87
|
+
ELSE ${this.tableName}.reset_at
|
|
88
|
+
END
|
|
89
|
+
RETURNING count, reset_at`,
|
|
90
|
+
[key, reset, now, reset]
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const row = rows[0]!;
|
|
94
|
+
return { count: row.count as number, reset: row.reset_at as number };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async reset(key: string): Promise<void> {
|
|
98
|
+
await this.client.query(`DELETE FROM ${this.tableName} WHERE key = $1`, [key]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { RedisApiKeyStore, type RedisClient } from "./redis-api-keys";
|
|
3
|
+
|
|
4
|
+
function mockRedisClient(): RedisClient {
|
|
5
|
+
const store = new Map<string, string>();
|
|
6
|
+
return {
|
|
7
|
+
async get(key: string) {
|
|
8
|
+
return store.get(key) ?? null;
|
|
9
|
+
},
|
|
10
|
+
async hget(_key: string, _field: string) {
|
|
11
|
+
return undefined;
|
|
12
|
+
},
|
|
13
|
+
async hgetall(key: string) {
|
|
14
|
+
const val = store.get(key);
|
|
15
|
+
if (!val) return null;
|
|
16
|
+
return JSON.parse(val) as Record<string, string>;
|
|
17
|
+
},
|
|
18
|
+
async set(key: string, value: string) {
|
|
19
|
+
store.set(key, value);
|
|
20
|
+
},
|
|
21
|
+
async del(key: string) {
|
|
22
|
+
return store.delete(key) ? 1 : 0;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("RedisApiKeyStore validate and setKey", async () => {
|
|
28
|
+
const client = mockRedisClient();
|
|
29
|
+
const store = new RedisApiKeyStore(client);
|
|
30
|
+
await store.setKey({ key: "sk-redis-test", scopes: ["read"] });
|
|
31
|
+
|
|
32
|
+
const result = await store.validate("sk-redis-test");
|
|
33
|
+
expect(result.authenticated).toBe(true);
|
|
34
|
+
expect(result.scopes).toEqual(["read"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("RedisApiKeyStore rejects unknown key", async () => {
|
|
38
|
+
const client = mockRedisClient();
|
|
39
|
+
const store = new RedisApiKeyStore(client);
|
|
40
|
+
const result = await store.validate("sk-unknown");
|
|
41
|
+
expect(result.authenticated).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("RedisApiKeyStore supports deleteKey", async () => {
|
|
45
|
+
const client = mockRedisClient();
|
|
46
|
+
const store = new RedisApiKeyStore(client);
|
|
47
|
+
await store.setKey({ key: "sk-deletable" });
|
|
48
|
+
await store.deleteKey("sk-deletable");
|
|
49
|
+
const result = await store.validate("sk-deletable");
|
|
50
|
+
expect(result.authenticated).toBe(false);
|
|
51
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ApiKeyEntry, AuthenticateResult } from "../api-keys";
|
|
2
|
+
|
|
3
|
+
export interface RedisClient {
|
|
4
|
+
get(key: string): Promise<string | null>;
|
|
5
|
+
hget(key: string, field: string): Promise<string | undefined>;
|
|
6
|
+
hgetall(key: string): Promise<Record<string, string> | null>;
|
|
7
|
+
set(key: string, value: string, opts?: { ex?: number }): Promise<unknown>;
|
|
8
|
+
del(key: string): Promise<number>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class RedisApiKeyStore {
|
|
12
|
+
constructor(
|
|
13
|
+
private client: RedisClient,
|
|
14
|
+
private keyPrefix = "gate:apikey:"
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
async validate(providedKey: string): Promise<AuthenticateResult> {
|
|
18
|
+
const keyHash = await sha256(providedKey);
|
|
19
|
+
const entry = await this.client.hgetall(`${this.keyPrefix}${keyHash}`);
|
|
20
|
+
if (!entry) {
|
|
21
|
+
return { authenticated: false, error: "Invalid API key" };
|
|
22
|
+
}
|
|
23
|
+
const scopes = entry.scopes ? (JSON.parse(entry.scopes) as string[]) : undefined;
|
|
24
|
+
const metadata = entry.metadata
|
|
25
|
+
? (JSON.parse(entry.metadata) as Record<string, unknown>)
|
|
26
|
+
: undefined;
|
|
27
|
+
return { authenticated: true, key: providedKey, scopes, metadata };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
verify = this.validate;
|
|
31
|
+
|
|
32
|
+
authenticate(options: { requiredScopes?: string[]; header?: string } = {}) {
|
|
33
|
+
const header = options.header ?? "Authorization";
|
|
34
|
+
const requiredScopes = options.requiredScopes ?? [];
|
|
35
|
+
|
|
36
|
+
return async (req: Request): Promise<AuthenticateResult> => {
|
|
37
|
+
const authHeader = req.headers.get(header);
|
|
38
|
+
if (!authHeader) {
|
|
39
|
+
return { authenticated: false, error: "Missing API key" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
43
|
+
const result = await this.verify(token);
|
|
44
|
+
|
|
45
|
+
if (!result.authenticated) return result;
|
|
46
|
+
|
|
47
|
+
if (requiredScopes.length > 0) {
|
|
48
|
+
const hasScopes = requiredScopes.every((s) => result.scopes?.includes(s));
|
|
49
|
+
if (!hasScopes) {
|
|
50
|
+
return { authenticated: false, error: "Insufficient permissions" };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async setKey(entry: ApiKeyEntry): Promise<void> {
|
|
59
|
+
const keyHash = await sha256(entry.key);
|
|
60
|
+
const data: Record<string, string> = {};
|
|
61
|
+
if (entry.scopes) data.scopes = JSON.stringify(entry.scopes);
|
|
62
|
+
if (entry.metadata) data.metadata = JSON.stringify(entry.metadata);
|
|
63
|
+
await this.client.set(`${this.keyPrefix}${keyHash}`, JSON.stringify(data));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async deleteKey(key: string): Promise<void> {
|
|
67
|
+
const keyHash = await sha256(key);
|
|
68
|
+
await this.client.del(`${this.keyPrefix}${keyHash}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function sha256(input: string): Promise<string> {
|
|
73
|
+
const encoder = new TextEncoder();
|
|
74
|
+
const data = encoder.encode(input);
|
|
75
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
76
|
+
return Array.from(new Uint8Array(hash))
|
|
77
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
78
|
+
.join("");
|
|
79
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { RedisIdempotencyStore, RedisRateLimitStore, type RedisClient } from "./redis";
|
|
3
|
+
|
|
4
|
+
function mockRedisClient(): RedisClient {
|
|
5
|
+
const store = new Map<string, { value: string; expires: number }>();
|
|
6
|
+
return {
|
|
7
|
+
async get(key: string) {
|
|
8
|
+
const entry = store.get(key);
|
|
9
|
+
if (!entry) return null;
|
|
10
|
+
if (Date.now() > entry.expires) {
|
|
11
|
+
store.delete(key);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return entry.value;
|
|
15
|
+
},
|
|
16
|
+
async set(_key: string, _value: string, _opts?: { ex?: number }) {
|
|
17
|
+
// not used by current impl
|
|
18
|
+
},
|
|
19
|
+
async setex(key: string, seconds: number, value: string) {
|
|
20
|
+
store.set(key, { value, expires: Date.now() + seconds * 1000 });
|
|
21
|
+
},
|
|
22
|
+
async incr(key: string) {
|
|
23
|
+
const entry = store.get(key);
|
|
24
|
+
if (!entry) {
|
|
25
|
+
store.set(key, { value: "1", expires: Infinity });
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
const next = Number(entry.value) + 1;
|
|
29
|
+
store.set(key, { value: String(next), expires: entry.expires });
|
|
30
|
+
return next;
|
|
31
|
+
},
|
|
32
|
+
async expire(key: string, _seconds: number) {
|
|
33
|
+
const entry = store.get(key);
|
|
34
|
+
if (entry) {
|
|
35
|
+
store.set(key, { ...entry, expires: Date.now() + _seconds * 1000 });
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
},
|
|
40
|
+
async del(key: string) {
|
|
41
|
+
return store.delete(key) ? 1 : 0;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("RedisIdempotencyStore set and get", async () => {
|
|
47
|
+
const client = mockRedisClient();
|
|
48
|
+
const store = new RedisIdempotencyStore(client);
|
|
49
|
+
await store.set("order:42", { status: "confirmed" }, 60_000);
|
|
50
|
+
const result = await store.get("order:42");
|
|
51
|
+
expect(result).toEqual({ status: "confirmed" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("RedisIdempotencyStore returns null for missing key", async () => {
|
|
55
|
+
const client = mockRedisClient();
|
|
56
|
+
const store = new RedisIdempotencyStore(client);
|
|
57
|
+
const result = await store.get("nonexistent");
|
|
58
|
+
expect(result).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("RedisRateLimitStore increment", async () => {
|
|
62
|
+
const client = mockRedisClient();
|
|
63
|
+
const store = new RedisRateLimitStore(client);
|
|
64
|
+
const first = await store.increment("user:1", 60_000);
|
|
65
|
+
expect(first.count).toBe(1);
|
|
66
|
+
const second = await store.increment("user:1", 60_000);
|
|
67
|
+
expect(second.count).toBe(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("RedisRateLimitStore reset", async () => {
|
|
71
|
+
const client = mockRedisClient();
|
|
72
|
+
const store = new RedisRateLimitStore(client);
|
|
73
|
+
await store.increment("user:1", 60_000);
|
|
74
|
+
await store.reset("user:1");
|
|
75
|
+
const result = await store.increment("user:1", 60_000);
|
|
76
|
+
expect(result.count).toBe(1);
|
|
77
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { IdempotencyStore } from "../idempotency";
|
|
2
|
+
import type { RateLimitStore } from "../rate-limit";
|
|
3
|
+
|
|
4
|
+
export interface RedisClient {
|
|
5
|
+
get(key: string): Promise<string | null>;
|
|
6
|
+
set(key: string, value: string, opts?: { ex?: number }): Promise<unknown>;
|
|
7
|
+
setex(key: string, seconds: number, value: string): Promise<unknown>;
|
|
8
|
+
incr(key: string): Promise<number>;
|
|
9
|
+
expire(key: string, seconds: number): Promise<number>;
|
|
10
|
+
del(key: string): Promise<number>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class RedisIdempotencyStore implements IdempotencyStore {
|
|
14
|
+
constructor(private client: RedisClient) {}
|
|
15
|
+
|
|
16
|
+
async get(key: string): Promise<unknown | null> {
|
|
17
|
+
const val = await this.client.get(key);
|
|
18
|
+
if (!val) return null;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(val) as unknown;
|
|
21
|
+
} catch {
|
|
22
|
+
return val;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async set(key: string, value: unknown, ttl: number): Promise<void> {
|
|
27
|
+
const serialized = typeof value === "string" ? value : JSON.stringify(value);
|
|
28
|
+
await this.client.setex(key, Math.ceil(ttl / 1000), serialized);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async delete(key: string): Promise<void> {
|
|
32
|
+
await this.client.del(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class RedisRateLimitStore implements RateLimitStore {
|
|
37
|
+
constructor(private client: RedisClient) {}
|
|
38
|
+
|
|
39
|
+
async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const windowSeconds = Math.ceil(windowMs / 1000);
|
|
42
|
+
const count = await this.client.incr(key);
|
|
43
|
+
|
|
44
|
+
if (count === 1) {
|
|
45
|
+
await this.client.expire(key, windowSeconds);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ttl = await this.client.get(key).then((v) => {
|
|
49
|
+
if (!v) return windowSeconds * 1000;
|
|
50
|
+
// approximate remaining TTL — we use incr+expire so ttl is reset
|
|
51
|
+
return windowMs;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return { count, reset: now + ttl };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async reset(key: string): Promise<void> {
|
|
58
|
+
await this.client.del(key);
|
|
59
|
+
}
|
|
60
|
+
}
|