@joinremba/gate 0.2.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 +85 -8
- package/package.json +11 -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/redis-api-keys.test.ts +51 -0
- package/src/stores/redis-api-keys.ts +79 -0
package/README.md
CHANGED
|
@@ -184,7 +184,86 @@ if (!result.authenticated) throw new AuthenticationError(result.error);
|
|
|
184
184
|
|
|
185
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
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,
|
|
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
|
+
```
|
|
188
267
|
|
|
189
268
|
### Errors (`@joinremba/gate/errors`)
|
|
190
269
|
|
|
@@ -317,15 +396,13 @@ app.post("/orders", async (req, res, next) => {
|
|
|
317
396
|
- In-memory idempotency store
|
|
318
397
|
- In-memory rate limiting store
|
|
319
398
|
|
|
320
|
-
**V1**
|
|
399
|
+
**V1** (current)
|
|
321
400
|
|
|
322
|
-
-
|
|
323
|
-
- Redis idempotency store
|
|
324
|
-
- Postgres idempotency store
|
|
325
|
-
- Rate limiting middleware
|
|
401
|
+
- Redis and Postgres stores for idempotency and rate limiting
|
|
326
402
|
- API key hashing and validation
|
|
327
|
-
-
|
|
328
|
-
-
|
|
403
|
+
- DB-backed API key stores (Redis, Postgres)
|
|
404
|
+
- Combined middleware (auth + rate limit + idempotency)
|
|
405
|
+
- Per-key rate limiting helper
|
|
329
406
|
|
|
330
407
|
**V2**
|
|
331
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",
|
|
@@ -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": [
|
|
@@ -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,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
|
+
}
|