@joinremba/gate 0.2.0 → 0.4.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 +84 -9
- package/package.json +12 -1
- package/src/api-keys.test.ts +53 -0
- package/src/api-keys.ts +65 -5
- package/src/idempotency.ts +24 -0
- package/src/index.test.ts +76 -0
- package/src/index.ts +183 -6
- package/src/rate-limit.ts +46 -2
- package/src/stores/postgres-api-keys.test.ts +86 -0
- package/src/stores/postgres-api-keys.ts +107 -0
- package/src/stores/postgres.ts +7 -1
- 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 +6 -0
- package/src/stores/redis.ts +5 -7
- package/src/validate.ts +9 -2
package/README.md
CHANGED
|
@@ -13,7 +13,6 @@ Gate is the API safety layer for TypeScript backends. It validates requests, for
|
|
|
13
13
|
- **Structured responses** — Consistent `{ success, data, error }` response envelope with `ok()` and `fail()` helpers.
|
|
14
14
|
- **Problem details** — RFC 9457 problem-details-style error format.
|
|
15
15
|
- **Pagination** — Standardised paginated response helper.
|
|
16
|
-
- **Request ID** — Automatic request ID generation and propagation.
|
|
17
16
|
- **Idempotency** — Prevent duplicate writes with idempotency keys. Ships with in-memory store; plug in Redis or Postgres.
|
|
18
17
|
- **Rate limiting** — Protect endpoints from abuse with configurable windows and limits.
|
|
19
18
|
- **API key management** — Rotatable, scoped API key authentication with Bearer token support.
|
|
@@ -184,7 +183,85 @@ if (!result.authenticated) throw new AuthenticationError(result.error);
|
|
|
184
183
|
|
|
185
184
|
**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
185
|
|
|
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,
|
|
186
|
+
**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.
|
|
187
|
+
|
|
188
|
+
#### Hashed API Keys
|
|
189
|
+
|
|
190
|
+
Store SHA-256 hashes instead of plaintext keys. Protects against memory dumps.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const validator = createApiKeyValidator([{ key: "sk-live-123", scopes: ["admin"] }], {
|
|
194
|
+
hashKeys: true,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await validator.verify("sk-live-123");
|
|
198
|
+
// -> { authenticated: true, key: "sk-live-123", scopes: ["admin"] }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
#### DB-backed API Key Stores
|
|
202
|
+
|
|
203
|
+
Validate keys against Postgres or Redis. Keys can be added/removed at runtime.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { PostgresApiKeyStore } from "@joinremba/gate/stores/postgres-api-keys";
|
|
207
|
+
|
|
208
|
+
const store = new PostgresApiKeyStore(pgClient);
|
|
209
|
+
await store.ensureTable();
|
|
210
|
+
|
|
211
|
+
// Add a key
|
|
212
|
+
await store.setKey({ key: "sk-live-abc", scopes: ["read"] }, expiresAt);
|
|
213
|
+
|
|
214
|
+
// Validate
|
|
215
|
+
const result = await store.verify("sk-live-abc");
|
|
216
|
+
|
|
217
|
+
// Remove
|
|
218
|
+
await store.deleteKey("sk-live-abc");
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { RedisApiKeyStore } from "@joinremba/gate/stores/redis-api-keys";
|
|
223
|
+
|
|
224
|
+
const store = new RedisApiKeyStore(redisClient);
|
|
225
|
+
await store.setKey({ key: "sk-redis-key", scopes: ["admin"] });
|
|
226
|
+
const result = await store.verify("sk-redis-key");
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Combined Middleware
|
|
230
|
+
|
|
231
|
+
Run auth, rate limiting, and idempotency in a single middleware call:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
const gate = createGate({
|
|
235
|
+
apiKeys: [{ key: "sk-admin" }],
|
|
236
|
+
rateLimit: { windowMs: 60_000, max: 100 },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
app.use(
|
|
240
|
+
gate.middleware({
|
|
241
|
+
auth: true,
|
|
242
|
+
requiredScopes: ["write"],
|
|
243
|
+
rateLimit: true,
|
|
244
|
+
idempotency: true,
|
|
245
|
+
excludePaths: ["/health", "/metrics"],
|
|
246
|
+
})
|
|
247
|
+
);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The middleware returns 401 for invalid/missing keys, 429 when rate limited, and caches idempotent responses automatically.
|
|
251
|
+
|
|
252
|
+
### Per-key Rate Limiting
|
|
253
|
+
|
|
254
|
+
Rate-limit by API key instead of IP:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { rateLimit, keyByApiKey } from "@joinremba/gate/rate-limit";
|
|
258
|
+
|
|
259
|
+
const limiter = rateLimit({
|
|
260
|
+
windowMs: 60_000,
|
|
261
|
+
max: 30,
|
|
262
|
+
keyFn: keyByApiKey,
|
|
263
|
+
});
|
|
264
|
+
```
|
|
188
265
|
|
|
189
266
|
### Errors (`@joinremba/gate/errors`)
|
|
190
267
|
|
|
@@ -317,15 +394,13 @@ app.post("/orders", async (req, res, next) => {
|
|
|
317
394
|
- In-memory idempotency store
|
|
318
395
|
- In-memory rate limiting store
|
|
319
396
|
|
|
320
|
-
**V1**
|
|
397
|
+
**V1** (current)
|
|
321
398
|
|
|
322
|
-
-
|
|
323
|
-
- Redis idempotency store
|
|
324
|
-
- Postgres idempotency store
|
|
325
|
-
- Rate limiting middleware
|
|
399
|
+
- Redis and Postgres stores for idempotency and rate limiting
|
|
326
400
|
- API key hashing and validation
|
|
327
|
-
-
|
|
328
|
-
-
|
|
401
|
+
- DB-backed API key stores (Redis, Postgres)
|
|
402
|
+
- Combined middleware (auth + rate limit + idempotency)
|
|
403
|
+
- Per-key rate limiting helper
|
|
329
404
|
|
|
330
405
|
**V2**
|
|
331
406
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinremba/gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -46,10 +46,20 @@
|
|
|
46
46
|
"import": "./src/stores/redis.ts",
|
|
47
47
|
"default": "./src/stores/redis.ts"
|
|
48
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
|
+
},
|
|
49
54
|
"./stores/postgres": {
|
|
50
55
|
"types": "./src/stores/postgres.ts",
|
|
51
56
|
"import": "./src/stores/postgres.ts",
|
|
52
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"
|
|
53
63
|
}
|
|
54
64
|
},
|
|
55
65
|
"files": [
|
|
@@ -100,6 +110,7 @@
|
|
|
100
110
|
"bun": ">=1.3.1"
|
|
101
111
|
},
|
|
102
112
|
"dependencies": {
|
|
113
|
+
"@joinremba/core": "^0.4.0",
|
|
103
114
|
"zod": "^4.4.2"
|
|
104
115
|
},
|
|
105
116
|
"devDependencies": {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createApiKeyValidator } from "./api-keys";
|
|
3
|
+
|
|
4
|
+
test("validate returns authenticated for matching key", () => {
|
|
5
|
+
const validator = createApiKeyValidator([{ key: "sk-live-abc", scopes: ["read"] }]);
|
|
6
|
+
const result = validator.validate("sk-live-abc");
|
|
7
|
+
expect(result.authenticated).toBe(true);
|
|
8
|
+
expect(result.scopes).toEqual(["read"]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("validate returns error for unknown key", () => {
|
|
12
|
+
const validator = createApiKeyValidator([{ key: "sk-live-abc" }]);
|
|
13
|
+
const result = validator.validate("sk-live-xyz");
|
|
14
|
+
expect(result.authenticated).toBe(false);
|
|
15
|
+
expect(result.error).toBe("Invalid API key");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("verify with hashKeys true hashes before lookup", async () => {
|
|
19
|
+
const validator = createApiKeyValidator(
|
|
20
|
+
[
|
|
21
|
+
{
|
|
22
|
+
key: "sk-live-123",
|
|
23
|
+
scopes: ["admin"],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
{ hashKeys: true }
|
|
27
|
+
);
|
|
28
|
+
// "sk-live-123" sha256 = 9418b81169b7...
|
|
29
|
+
const result = await validator.verify("sk-live-123");
|
|
30
|
+
expect(result.authenticated).toBe(true);
|
|
31
|
+
expect(result.scopes).toEqual(["admin"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("authenticate middleware reads Authorization header", async () => {
|
|
35
|
+
const validator = createApiKeyValidator([{ key: "sk-test", scopes: ["read"] }]);
|
|
36
|
+
const auth = validator.authenticate({ requiredScopes: ["read"] });
|
|
37
|
+
const req = new Request("http://localhost", {
|
|
38
|
+
headers: { Authorization: "Bearer sk-test" },
|
|
39
|
+
});
|
|
40
|
+
const result = await auth(req);
|
|
41
|
+
expect(result.authenticated).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("authenticate rejects missing scopes", async () => {
|
|
45
|
+
const validator = createApiKeyValidator([{ key: "sk-test", scopes: ["read"] }]);
|
|
46
|
+
const auth = validator.authenticate({ requiredScopes: ["write"] });
|
|
47
|
+
const req = new Request("http://localhost", {
|
|
48
|
+
headers: { Authorization: "Bearer sk-test" },
|
|
49
|
+
});
|
|
50
|
+
const result = await auth(req);
|
|
51
|
+
expect(result.authenticated).toBe(false);
|
|
52
|
+
expect(result.error).toBe("Insufficient permissions");
|
|
53
|
+
});
|
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,44 @@ 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
|
+
}
|
|
43
|
+
|
|
44
|
+
let hashCache: Map<
|
|
45
|
+
string,
|
|
46
|
+
{ key: string; scopes?: string[]; metadata?: Record<string, unknown> }
|
|
47
|
+
> | null = null;
|
|
48
|
+
|
|
49
|
+
async function ensureHashCache() {
|
|
50
|
+
if (!hashCache) {
|
|
51
|
+
hashCache = new Map();
|
|
52
|
+
for (const [, entry] of keyMap) {
|
|
53
|
+
hashCache.set(await sha256(entry.key), entry);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return hashCache;
|
|
57
|
+
}
|
|
21
58
|
|
|
22
59
|
return {
|
|
23
60
|
validate(providedKey: string): AuthenticateResult {
|
|
@@ -25,21 +62,44 @@ export function createApiKeyValidator(keys: ApiKeyEntry[]) {
|
|
|
25
62
|
if (!entry) {
|
|
26
63
|
return { authenticated: false, error: "Invalid API key" };
|
|
27
64
|
}
|
|
28
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
authenticated: true,
|
|
67
|
+
key: entry.key,
|
|
68
|
+
scopes: entry.scopes,
|
|
69
|
+
metadata: entry.metadata,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async verify(providedKey: string): Promise<AuthenticateResult> {
|
|
74
|
+
if (!hashKeys) {
|
|
75
|
+
return this.validate(providedKey);
|
|
76
|
+
}
|
|
77
|
+
const cache = await ensureHashCache();
|
|
78
|
+
const keyHash = await sha256(providedKey);
|
|
79
|
+
const entry = cache.get(keyHash);
|
|
80
|
+
if (!entry) {
|
|
81
|
+
return { authenticated: false, error: "Invalid API key" };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
authenticated: true,
|
|
85
|
+
key: providedKey,
|
|
86
|
+
scopes: entry.scopes,
|
|
87
|
+
metadata: entry.metadata,
|
|
88
|
+
};
|
|
29
89
|
},
|
|
30
90
|
|
|
31
91
|
authenticate(options: AuthenticateOptions = {}) {
|
|
32
92
|
const header = options.header ?? "Authorization";
|
|
33
93
|
const requiredScopes = options.requiredScopes ?? [];
|
|
34
94
|
|
|
35
|
-
return (req: Request): AuthenticateResult => {
|
|
95
|
+
return async (req: Request): Promise<AuthenticateResult> => {
|
|
36
96
|
const authHeader = req.headers.get(header);
|
|
37
97
|
if (!authHeader) {
|
|
38
98
|
return { authenticated: false, error: "Missing API key" };
|
|
39
99
|
}
|
|
40
100
|
|
|
41
101
|
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
42
|
-
const result = this.validate(token);
|
|
102
|
+
const result = hashKeys ? await this.verify(token) : this.validate(token);
|
|
43
103
|
|
|
44
104
|
if (!result.authenticated) return result;
|
|
45
105
|
|
package/src/idempotency.ts
CHANGED
|
@@ -6,6 +6,25 @@ export interface IdempotencyStore {
|
|
|
6
6
|
|
|
7
7
|
export class InMemoryStore implements IdempotencyStore {
|
|
8
8
|
private store = new Map<string, { value: unknown; expires: number }>();
|
|
9
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
10
|
+
|
|
11
|
+
constructor() {
|
|
12
|
+
this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
|
|
13
|
+
if (
|
|
14
|
+
this.cleanupInterval &&
|
|
15
|
+
typeof this.cleanupInterval === "object" &&
|
|
16
|
+
"unref" in this.cleanupInterval
|
|
17
|
+
) {
|
|
18
|
+
this.cleanupInterval.unref();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private evictExpired(): void {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
for (const [key, entry] of this.store) {
|
|
25
|
+
if (now > entry.expires) this.store.delete(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
9
28
|
|
|
10
29
|
async get(key: string): Promise<unknown | null> {
|
|
11
30
|
const entry = this.store.get(key);
|
|
@@ -24,6 +43,11 @@ export class InMemoryStore implements IdempotencyStore {
|
|
|
24
43
|
async delete(key: string): Promise<void> {
|
|
25
44
|
this.store.delete(key);
|
|
26
45
|
}
|
|
46
|
+
|
|
47
|
+
dispose(): void {
|
|
48
|
+
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
|
|
49
|
+
this.store.clear();
|
|
50
|
+
}
|
|
27
51
|
}
|
|
28
52
|
|
|
29
53
|
export interface IdempotencyOptions {
|
package/src/index.test.ts
CHANGED
|
@@ -95,6 +95,16 @@ describe("rate limit", () => {
|
|
|
95
95
|
expect(result.allowed).toBe(true);
|
|
96
96
|
expect(result.remaining).toBe(99);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
test("accepts string key directly", async () => {
|
|
100
|
+
const gate = createGate({
|
|
101
|
+
rateLimit: { windowMs: 60000, max: 10 },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = await gate.rateLimit.check("custom:user-42");
|
|
105
|
+
expect(result.allowed).toBe(true);
|
|
106
|
+
expect(result.remaining).toBe(9);
|
|
107
|
+
});
|
|
98
108
|
});
|
|
99
109
|
|
|
100
110
|
describe("api keys", () => {
|
|
@@ -114,3 +124,69 @@ describe("api keys", () => {
|
|
|
114
124
|
expect(result.authenticated).toBe(false);
|
|
115
125
|
});
|
|
116
126
|
});
|
|
127
|
+
|
|
128
|
+
describe("keyByApiKey", () => {
|
|
129
|
+
test("uses API key from Authorization header", async () => {
|
|
130
|
+
const { keyByApiKey } = await import("./rate-limit");
|
|
131
|
+
const req = new Request("http://localhost", {
|
|
132
|
+
headers: { Authorization: "Bearer sk-live-abc123" },
|
|
133
|
+
});
|
|
134
|
+
const key = keyByApiKey(req);
|
|
135
|
+
expect(key).toStartWith("ak:sk-live-abc1");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("falls back to IP when no auth header", async () => {
|
|
139
|
+
const { keyByApiKey } = await import("./rate-limit");
|
|
140
|
+
const req = new Request("http://localhost");
|
|
141
|
+
const key = keyByApiKey(req);
|
|
142
|
+
expect(typeof key).toBe("string");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("middleware", () => {
|
|
147
|
+
test("passes through when no features enabled", async () => {
|
|
148
|
+
const gate = createGate();
|
|
149
|
+
const mw = gate.middleware();
|
|
150
|
+
const req = new Request("http://localhost/api");
|
|
151
|
+
let called = false;
|
|
152
|
+
const res = await mw(req, async () => {
|
|
153
|
+
called = true;
|
|
154
|
+
return new Response("ok");
|
|
155
|
+
});
|
|
156
|
+
expect(called).toBe(true);
|
|
157
|
+
expect(res).toBeInstanceOf(Response);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("rejects request when auth fails", async () => {
|
|
161
|
+
const gate = createGate({ apiKeys: [{ key: "sk-valid" }] });
|
|
162
|
+
const mw = gate.middleware({ auth: true });
|
|
163
|
+
const req = new Request("http://localhost/api", {
|
|
164
|
+
headers: { Authorization: "Bearer sk-wrong" },
|
|
165
|
+
});
|
|
166
|
+
const res = await mw(req, async () => new Response("ok"));
|
|
167
|
+
expect(res!.status).toBe(401);
|
|
168
|
+
const body = (await res!.json()) as Record<string, unknown>;
|
|
169
|
+
expect(body.success).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("rejects when rate limit exceeded", async () => {
|
|
173
|
+
const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
|
|
174
|
+
const mw = gate.middleware({ rateLimit: true });
|
|
175
|
+
const req = new Request("http://localhost/api");
|
|
176
|
+
const res = await mw(req, async () => new Response("ok"));
|
|
177
|
+
expect(res!.status).toBe(429);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("skips excluded paths", async () => {
|
|
181
|
+
const gate = createGate({ rateLimit: { windowMs: 60000, max: 0 } });
|
|
182
|
+
const mw = gate.middleware({ rateLimit: true, excludePaths: ["/health"] });
|
|
183
|
+
const req = new Request("http://localhost/health");
|
|
184
|
+
let called = false;
|
|
185
|
+
const res = await mw(req, async () => {
|
|
186
|
+
called = true;
|
|
187
|
+
return new Response("ok");
|
|
188
|
+
});
|
|
189
|
+
expect(called).toBe(true);
|
|
190
|
+
expect(res!.status).toBe(200);
|
|
191
|
+
});
|
|
192
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
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
|
+
import type { RateLimitStore, RateLimitCheckResult } from "./rate-limit";
|
|
5
6
|
import { createApiKeyValidator } from "./api-keys";
|
|
6
|
-
import type { ApiKeyEntry } from "./api-keys";
|
|
7
|
+
import type { ApiKeyEntry, AuthenticateResult } from "./api-keys";
|
|
7
8
|
import type { IdempotencyStore } from "./idempotency";
|
|
9
|
+
import type { Client } from "@joinremba/core";
|
|
10
|
+
import { NetworkError } from "@joinremba/core";
|
|
11
|
+
|
|
12
|
+
const gateAuthStore = new WeakMap<Request, AuthenticateResult>();
|
|
13
|
+
const gateIdempotencyStore = new WeakMap<Request, string>();
|
|
14
|
+
const gateRateLimitStore = new WeakMap<Request, RateLimitCheckResult>();
|
|
15
|
+
|
|
16
|
+
export function getGateAuth(req: Request): AuthenticateResult | undefined {
|
|
17
|
+
return gateAuthStore.get(req);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getGateIdempotencyKey(req: Request): string | undefined {
|
|
21
|
+
return gateIdempotencyStore.get(req);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getGateRateLimit(req: Request): RateLimitCheckResult | undefined {
|
|
25
|
+
return gateRateLimitStore.get(req);
|
|
26
|
+
}
|
|
8
27
|
import {
|
|
9
28
|
GateError,
|
|
10
29
|
ValidationError,
|
|
@@ -23,6 +42,7 @@ export {
|
|
|
23
42
|
InMemoryStore,
|
|
24
43
|
rateLimit,
|
|
25
44
|
InMemoryRateLimitStore,
|
|
45
|
+
keyByApiKey,
|
|
26
46
|
createApiKeyValidator,
|
|
27
47
|
GateError,
|
|
28
48
|
ValidationError,
|
|
@@ -52,12 +72,25 @@ export type {
|
|
|
52
72
|
AuthenticateOptions,
|
|
53
73
|
AuthenticateResult,
|
|
54
74
|
ApiKeyValidator,
|
|
75
|
+
ApiKeyValidatorOptions,
|
|
55
76
|
} from "./api-keys";
|
|
56
77
|
|
|
57
78
|
export type Middleware = (req: Request, next?: () => Promise<Response>) => Promise<Response | null>;
|
|
58
79
|
|
|
80
|
+
export interface MiddlewareOptions {
|
|
81
|
+
auth?: boolean;
|
|
82
|
+
requiredScopes?: string[];
|
|
83
|
+
rateLimit?: boolean;
|
|
84
|
+
idempotency?: boolean;
|
|
85
|
+
/** Override the max for this specific middleware. */
|
|
86
|
+
rateLimitMax?: number;
|
|
87
|
+
/** Paths to skip entirely. */
|
|
88
|
+
excludePaths?: string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
59
91
|
export interface GateOptions {
|
|
60
92
|
apiKeys?: ApiKeyEntry[];
|
|
93
|
+
client?: Client;
|
|
61
94
|
idempotency?: {
|
|
62
95
|
store?: IdempotencyStore;
|
|
63
96
|
keyHeader?: string;
|
|
@@ -66,10 +99,19 @@ export interface GateOptions {
|
|
|
66
99
|
rateLimit?: {
|
|
67
100
|
windowMs?: number;
|
|
68
101
|
max?: number;
|
|
69
|
-
|
|
102
|
+
store?: RateLimitStore;
|
|
103
|
+
keyFn?: (req: Request) => string;
|
|
70
104
|
};
|
|
71
105
|
}
|
|
72
106
|
|
|
107
|
+
export type MiddlewareResult =
|
|
108
|
+
| {
|
|
109
|
+
passed: true;
|
|
110
|
+
auth?: { key: string; scopes?: string[] };
|
|
111
|
+
rateLimit?: { remaining: number; reset: number };
|
|
112
|
+
}
|
|
113
|
+
| { passed: false; status: number; body: unknown };
|
|
114
|
+
|
|
73
115
|
export interface Gate {
|
|
74
116
|
validate: typeof validateRequest;
|
|
75
117
|
ok: typeof ok;
|
|
@@ -79,10 +121,12 @@ export interface Gate {
|
|
|
79
121
|
idempotency: ReturnType<typeof idempotency>;
|
|
80
122
|
rateLimit: ReturnType<typeof rateLimit>;
|
|
81
123
|
apiKeys: ReturnType<typeof createApiKeyValidator>;
|
|
82
|
-
middleware(): Middleware;
|
|
124
|
+
middleware(opts?: MiddlewareOptions): Middleware;
|
|
83
125
|
}
|
|
84
126
|
|
|
85
127
|
export function createGate(options: GateOptions = {}): Gate {
|
|
128
|
+
const client = options.client;
|
|
129
|
+
|
|
86
130
|
const idempInstance = idempotency({
|
|
87
131
|
store: options.idempotency?.store ?? new InMemoryStore(),
|
|
88
132
|
keyHeader: options.idempotency?.keyHeader,
|
|
@@ -92,10 +136,71 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
92
136
|
const rlInstance = rateLimit({
|
|
93
137
|
windowMs: options.rateLimit?.windowMs,
|
|
94
138
|
max: options.rateLimit?.max,
|
|
139
|
+
store: options.rateLimit?.store,
|
|
140
|
+
keyFn: options.rateLimit?.keyFn,
|
|
95
141
|
});
|
|
96
142
|
|
|
97
143
|
const apiKeyValidator = createApiKeyValidator(options.apiKeys ?? []);
|
|
98
144
|
|
|
145
|
+
if (client) {
|
|
146
|
+
const origCheck = rlInstance.check.bind(rlInstance);
|
|
147
|
+
rlInstance.check = async (reqOrKey) => {
|
|
148
|
+
const key = typeof reqOrKey === "string" ? reqOrKey : rlInstance.keyFn(reqOrKey);
|
|
149
|
+
try {
|
|
150
|
+
return await client.checkRateLimit(key);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err instanceof NetworkError) return origCheck(reqOrKey);
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const origIdemGet = idempInstance.getResponse.bind(idempInstance);
|
|
158
|
+
const origIdemSet = idempInstance.setResponse.bind(idempInstance);
|
|
159
|
+
idempInstance.getResponse = async (key: string) => {
|
|
160
|
+
try {
|
|
161
|
+
const result = await client.checkIdempotency(key);
|
|
162
|
+
if (result.exists) return result.response;
|
|
163
|
+
return null;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err instanceof NetworkError) return origIdemGet(key);
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
idempInstance.setResponse = async (key: string, response: unknown) => {
|
|
170
|
+
try {
|
|
171
|
+
await client.setIdempotency(key, response);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err instanceof NetworkError) return origIdemSet(key, response);
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const origAuthenticate = apiKeyValidator.authenticate.bind(apiKeyValidator);
|
|
179
|
+
apiKeyValidator.authenticate = (authOptions) => {
|
|
180
|
+
const handler = origAuthenticate(authOptions);
|
|
181
|
+
return async (req) => {
|
|
182
|
+
const token = req.headers
|
|
183
|
+
.get("Authorization")
|
|
184
|
+
?.replace(/^Bearer\s+/i, "")
|
|
185
|
+
.trim();
|
|
186
|
+
if (token) {
|
|
187
|
+
try {
|
|
188
|
+
const result = await client.verifyApiKey(token);
|
|
189
|
+
if (result.valid) {
|
|
190
|
+
return { authenticated: true, key: token, scopes: result.scopes };
|
|
191
|
+
}
|
|
192
|
+
return { authenticated: false, error: "Invalid API key" };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (!(err instanceof NetworkError)) throw err;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return handler(req);
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const defaultFail = (message: string, code?: string) => fail(message, code ?? "UNAUTHORIZED");
|
|
203
|
+
|
|
99
204
|
const gate: Gate = {
|
|
100
205
|
validate: validateRequest,
|
|
101
206
|
ok,
|
|
@@ -106,10 +211,82 @@ export function createGate(options: GateOptions = {}): Gate {
|
|
|
106
211
|
rateLimit: rlInstance,
|
|
107
212
|
apiKeys: apiKeyValidator,
|
|
108
213
|
|
|
109
|
-
middleware() {
|
|
214
|
+
middleware(opts?: MiddlewareOptions) {
|
|
215
|
+
const {
|
|
216
|
+
auth = options.apiKeys != null && options.apiKeys.length > 0,
|
|
217
|
+
requiredScopes,
|
|
218
|
+
rateLimit: enableRl = options.rateLimit != null,
|
|
219
|
+
idempotency: enableIdem = false,
|
|
220
|
+
excludePaths = [],
|
|
221
|
+
} = opts ?? {};
|
|
222
|
+
|
|
110
223
|
return async (req: Request, next?: () => Promise<Response>) => {
|
|
111
224
|
if (!next) return null;
|
|
112
|
-
|
|
225
|
+
|
|
226
|
+
const path = new URL(req.url).pathname;
|
|
227
|
+
if (excludePaths.some((p) => path === p || path.startsWith(p))) {
|
|
228
|
+
return next();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Rate limit check
|
|
232
|
+
if (enableRl) {
|
|
233
|
+
const rlResult = await rlInstance.check(req);
|
|
234
|
+
gateRateLimitStore.set(req, rlResult);
|
|
235
|
+
if (!rlResult.allowed) {
|
|
236
|
+
const body = defaultFail("Too many requests", "RATE_LIMIT_EXCEEDED");
|
|
237
|
+
return new Response(JSON.stringify(body), {
|
|
238
|
+
status: 429,
|
|
239
|
+
headers: {
|
|
240
|
+
"Content-Type": "application/json",
|
|
241
|
+
"Retry-After": String(Math.ceil((rlResult.reset - Date.now()) / 1000)),
|
|
242
|
+
"X-RateLimit-Remaining": "0",
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Auth check
|
|
249
|
+
if (auth) {
|
|
250
|
+
const authFn = apiKeyValidator.authenticate({ requiredScopes });
|
|
251
|
+
const authResult = await authFn(req);
|
|
252
|
+
if (!authResult.authenticated) {
|
|
253
|
+
const body = defaultFail(authResult.error ?? "Unauthorized", "AUTHENTICATION_ERROR");
|
|
254
|
+
return new Response(JSON.stringify(body), {
|
|
255
|
+
status: 401,
|
|
256
|
+
headers: { "Content-Type": "application/json" },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
gateAuthStore.set(req, authResult);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Idempotency check
|
|
263
|
+
if (enableIdem) {
|
|
264
|
+
const idemKey = req.headers.get(idempInstance.keyHeader);
|
|
265
|
+
if (idemKey) {
|
|
266
|
+
const cached = await idempInstance.getResponse(idemKey);
|
|
267
|
+
if (cached) {
|
|
268
|
+
return new Response(JSON.stringify(cached), {
|
|
269
|
+
status: 200,
|
|
270
|
+
headers: { "Content-Type": "application/json" },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
gateIdempotencyStore.set(req, idemKey);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const response = await next();
|
|
278
|
+
if (!response) return null;
|
|
279
|
+
|
|
280
|
+
// Store response for idempotency
|
|
281
|
+
if (enableIdem) {
|
|
282
|
+
const idemKey = gateIdempotencyStore.get(req);
|
|
283
|
+
if (idemKey && response.status < 500) {
|
|
284
|
+
const body = await response.clone().json();
|
|
285
|
+
await idempInstance.setResponse(idemKey, body);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return response;
|
|
113
290
|
};
|
|
114
291
|
},
|
|
115
292
|
};
|
package/src/rate-limit.ts
CHANGED
|
@@ -5,6 +5,25 @@ export interface RateLimitStore {
|
|
|
5
5
|
|
|
6
6
|
export class InMemoryRateLimitStore implements RateLimitStore {
|
|
7
7
|
private store = new Map<string, { count: number; reset: number }>();
|
|
8
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
9
|
+
|
|
10
|
+
constructor() {
|
|
11
|
+
this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
|
|
12
|
+
if (
|
|
13
|
+
this.cleanupInterval &&
|
|
14
|
+
typeof this.cleanupInterval === "object" &&
|
|
15
|
+
"unref" in this.cleanupInterval
|
|
16
|
+
) {
|
|
17
|
+
this.cleanupInterval.unref();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private evictExpired(): void {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
for (const [key, entry] of this.store) {
|
|
24
|
+
if (now > entry.reset) this.store.delete(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
8
27
|
|
|
9
28
|
async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
|
|
10
29
|
const now = Date.now();
|
|
@@ -23,6 +42,11 @@ export class InMemoryRateLimitStore implements RateLimitStore {
|
|
|
23
42
|
async reset(key: string): Promise<void> {
|
|
24
43
|
this.store.delete(key);
|
|
25
44
|
}
|
|
45
|
+
|
|
46
|
+
dispose(): void {
|
|
47
|
+
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
|
|
48
|
+
this.store.clear();
|
|
49
|
+
}
|
|
26
50
|
}
|
|
27
51
|
|
|
28
52
|
export type RateLimitStrategy = "fixed" | "sliding";
|
|
@@ -35,10 +59,24 @@ export interface RateLimitOptions {
|
|
|
35
59
|
keyFn?: (req: Request) => string;
|
|
36
60
|
}
|
|
37
61
|
|
|
62
|
+
/** Rate-limit by API key (Bearer token from Authorization header). Falls back to IP. */
|
|
63
|
+
export function keyByApiKey(req: Request): string {
|
|
64
|
+
const auth = req.headers.get("authorization");
|
|
65
|
+
if (auth) {
|
|
66
|
+
const token = auth.replace(/^Bearer\s+/i, "").trim();
|
|
67
|
+
if (token) return `ak:${token}`;
|
|
68
|
+
}
|
|
69
|
+
return req.headers.get("x-forwarded-for") ?? "global";
|
|
70
|
+
}
|
|
71
|
+
|
|
38
72
|
export function rateLimit(options: RateLimitOptions = {}) {
|
|
39
73
|
const windowMs = options.windowMs ?? 60_000;
|
|
40
74
|
const max = options.max ?? 100;
|
|
41
75
|
const store = options.store ?? new InMemoryRateLimitStore();
|
|
76
|
+
|
|
77
|
+
if (options.strategy === "sliding") {
|
|
78
|
+
throw new Error("Sliding window rate limiting is not yet implemented. Use 'fixed' (default).");
|
|
79
|
+
}
|
|
42
80
|
const keyFn =
|
|
43
81
|
options.keyFn ??
|
|
44
82
|
((req: Request) => {
|
|
@@ -52,8 +90,8 @@ export function rateLimit(options: RateLimitOptions = {}) {
|
|
|
52
90
|
store,
|
|
53
91
|
keyFn,
|
|
54
92
|
|
|
55
|
-
async check(
|
|
56
|
-
const key = keyFn(
|
|
93
|
+
async check(reqOrKey: Request | string): Promise<RateLimitCheckResult> {
|
|
94
|
+
const key = typeof reqOrKey === "string" ? reqOrKey : keyFn(reqOrKey);
|
|
57
95
|
const { count, reset } = await store.increment(`rl:${key}`, windowMs);
|
|
58
96
|
return {
|
|
59
97
|
allowed: count <= max,
|
|
@@ -65,3 +103,9 @@ export function rateLimit(options: RateLimitOptions = {}) {
|
|
|
65
103
|
}
|
|
66
104
|
|
|
67
105
|
export type RateLimitInstance = ReturnType<typeof rateLimit>;
|
|
106
|
+
|
|
107
|
+
export type RateLimitCheckResult = {
|
|
108
|
+
allowed: boolean;
|
|
109
|
+
remaining: number;
|
|
110
|
+
reset: number;
|
|
111
|
+
};
|
|
@@ -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
|
+
}
|
package/src/stores/postgres.ts
CHANGED
|
@@ -90,7 +90,13 @@ export class PostgresRateLimitStore implements RateLimitStore {
|
|
|
90
90
|
[key, reset, now, reset]
|
|
91
91
|
);
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
// Best-effort locking hint to prevent write skew under concurrency.
|
|
94
|
+
// Not a full serializable isolation — for production, set the table's
|
|
95
|
+
// fillfactor low or use advisory locks.
|
|
96
|
+
await this.client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [key]);
|
|
97
|
+
|
|
98
|
+
const row = rows[0];
|
|
99
|
+
if (!row) return { count: 0, reset: Date.now() + windowMs };
|
|
94
100
|
return { count: row.count as number, reset: row.reset_at as number };
|
|
95
101
|
}
|
|
96
102
|
|
|
@@ -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
|
+
}
|
package/src/stores/redis.test.ts
CHANGED
|
@@ -29,6 +29,12 @@ function mockRedisClient(): RedisClient {
|
|
|
29
29
|
store.set(key, { value: String(next), expires: entry.expires });
|
|
30
30
|
return next;
|
|
31
31
|
},
|
|
32
|
+
async ttl(key: string) {
|
|
33
|
+
const entry = store.get(key);
|
|
34
|
+
if (!entry) return -2;
|
|
35
|
+
const remaining = Math.ceil((entry.expires - Date.now()) / 1000);
|
|
36
|
+
return remaining > 0 ? remaining : -2;
|
|
37
|
+
},
|
|
32
38
|
async expire(key: string, _seconds: number) {
|
|
33
39
|
const entry = store.get(key);
|
|
34
40
|
if (entry) {
|
package/src/stores/redis.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface RedisClient {
|
|
|
7
7
|
setex(key: string, seconds: number, value: string): Promise<unknown>;
|
|
8
8
|
incr(key: string): Promise<number>;
|
|
9
9
|
expire(key: string, seconds: number): Promise<number>;
|
|
10
|
+
ttl(key: string): Promise<number>;
|
|
10
11
|
del(key: string): Promise<number>;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -37,7 +38,6 @@ export class RedisRateLimitStore implements RateLimitStore {
|
|
|
37
38
|
constructor(private client: RedisClient) {}
|
|
38
39
|
|
|
39
40
|
async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
|
|
40
|
-
const now = Date.now();
|
|
41
41
|
const windowSeconds = Math.ceil(windowMs / 1000);
|
|
42
42
|
const count = await this.client.incr(key);
|
|
43
43
|
|
|
@@ -45,13 +45,11 @@ export class RedisRateLimitStore implements RateLimitStore {
|
|
|
45
45
|
await this.client.expire(key, windowSeconds);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return windowMs;
|
|
52
|
-
});
|
|
48
|
+
const ttlSeconds = await this.client.ttl(key);
|
|
49
|
+
const remainingMs = ttlSeconds > 0 ? ttlSeconds * 1000 : windowMs;
|
|
50
|
+
const reset = Date.now() + remainingMs;
|
|
53
51
|
|
|
54
|
-
return { count, reset
|
|
52
|
+
return { count, reset };
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
async reset(key: string): Promise<void> {
|
package/src/validate.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function validateRequest(
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function validate(schemas: ValidationSchemas) {
|
|
66
|
-
return (req: Request) => {
|
|
66
|
+
return async (req: Request) => {
|
|
67
67
|
const url = new URL(req.url);
|
|
68
68
|
|
|
69
69
|
const searchParams: Record<string, string> = {};
|
|
@@ -71,8 +71,15 @@ export function validate(schemas: ValidationSchemas) {
|
|
|
71
71
|
searchParams[k] = v;
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
let body: unknown;
|
|
75
|
+
try {
|
|
76
|
+
body = await req.json();
|
|
77
|
+
} catch {
|
|
78
|
+
body = undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
74
81
|
const result = validateRequest(schemas, {
|
|
75
|
-
body
|
|
82
|
+
body,
|
|
76
83
|
query: searchParams,
|
|
77
84
|
headers: Object.fromEntries(req.headers.entries()),
|
|
78
85
|
});
|