@donotlb/keypal 0.1.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/LICENSE +22 -0
- package/README.md +1037 -0
- package/dist/drizzle/schema.d.mts +87 -0
- package/dist/drizzle/schema.d.ts +87 -0
- package/dist/drizzle/schema.mjs +7 -0
- package/dist/index.d.mts +606 -0
- package/dist/index.d.ts +606 -0
- package/dist/index.mjs +7 -0
- package/dist/shared/keypal.C-UeOmUF.mjs +7 -0
- package/dist/shared/keypal.kItV-5pB.d.mts +194 -0
- package/dist/shared/keypal.kItV-5pB.d.ts +194 -0
- package/dist/shared/keypal.lTVSZWgp.mjs +7 -0
- package/dist/storage/drizzle.d.mts +192 -0
- package/dist/storage/drizzle.d.ts +192 -0
- package/dist/storage/drizzle.mjs +7 -0
- package/dist/storage/memory.d.mts +27 -0
- package/dist/storage/memory.d.ts +27 -0
- package/dist/storage/memory.mjs +7 -0
- package/dist/storage/redis.d.mts +42 -0
- package/dist/storage/redis.d.ts +42 -0
- package/dist/storage/redis.mjs +7 -0
- package/package.json +122 -0
package/README.md
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
# keypal
|
|
2
|
+
|
|
3
|
+
[](https://github.com/izadoesdev/keypal/actions/workflows/test.yml)
|
|
4
|
+
[](https://github.com/izadoesdev/keypal/actions/workflows/benchmark.yml)
|
|
5
|
+
[](https://badge.fury.io/js/keypal)
|
|
6
|
+
|
|
7
|
+
A TypeScript library for secure API key management with cryptographic hashing, expiration, scopes, and pluggable storage.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Secure by Default**: SHA-256/SHA-512 hashing with optional salt and timing-safe comparison
|
|
12
|
+
- **Smart Key Detection**: Automatically extracts keys from `Authorization`, `x-api-key`, or custom headers
|
|
13
|
+
- **Built-in Caching**: Optional in-memory or Redis caching for validated keys
|
|
14
|
+
- **Flexible Storage**: Memory, Redis, Drizzle ORM, Prisma, and Kysely adapters included
|
|
15
|
+
- **Scope-based Permissions**: Fine-grained access control with resource-specific scopes
|
|
16
|
+
- **Tags**: Organize and find keys by tags
|
|
17
|
+
- **Key Management**: Enable/disable, rotate, and soft-revoke keys with audit trails
|
|
18
|
+
- **Audit Logging**: Track who did what, when, and why (opt-in)
|
|
19
|
+
- **TypeScript**: Full type safety
|
|
20
|
+
- **Zero Config**: Works out of the box with sensible defaults
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install keypal
|
|
26
|
+
# or
|
|
27
|
+
bun add keypal
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { createKeys } from 'keypal'
|
|
34
|
+
|
|
35
|
+
const keys = createKeys({
|
|
36
|
+
prefix: 'sk_',
|
|
37
|
+
cache: true,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// Create a key
|
|
41
|
+
const { key, record } = await keys.create({
|
|
42
|
+
ownerId: 'user_123',
|
|
43
|
+
scopes: ['read', 'write'],
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Verify from headers
|
|
47
|
+
const result = await keys.verify(request.headers)
|
|
48
|
+
if (result.valid) {
|
|
49
|
+
console.log('Authenticated:', result.record.metadata.ownerId)
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import Redis from 'ioredis'
|
|
57
|
+
|
|
58
|
+
const redis = new Redis()
|
|
59
|
+
|
|
60
|
+
const keys = createKeys({
|
|
61
|
+
// Key generation
|
|
62
|
+
prefix: 'sk_prod_',
|
|
63
|
+
length: 32,
|
|
64
|
+
alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
|
|
65
|
+
|
|
66
|
+
// Security
|
|
67
|
+
algorithm: 'sha256', // or 'sha512'
|
|
68
|
+
salt: process.env.API_KEY_SALT,
|
|
69
|
+
|
|
70
|
+
// Storage (memory by default)
|
|
71
|
+
storage: 'redis', // or custom Storage instance
|
|
72
|
+
redis, // required when storage/cache is 'redis'
|
|
73
|
+
|
|
74
|
+
// Caching
|
|
75
|
+
cache: true, // in-memory cache
|
|
76
|
+
// cache: 'redis', // Redis cache
|
|
77
|
+
cacheTtl: 60,
|
|
78
|
+
|
|
79
|
+
// Revocation
|
|
80
|
+
revokedKeyTtl: 604800, // TTL for revoked keys in Redis (7 days), set to 0 to keep forever
|
|
81
|
+
|
|
82
|
+
// Usage tracking
|
|
83
|
+
autoTrackUsage: true, // Automatically update lastUsedAt on verify
|
|
84
|
+
|
|
85
|
+
// Audit logging (opt-in)
|
|
86
|
+
auditLogs: true, // Enable audit logging
|
|
87
|
+
auditContext: { // Default context for all audit logs (optional)
|
|
88
|
+
userId: 'system',
|
|
89
|
+
metadata: { service: 'api' }
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// Header detection
|
|
93
|
+
headerNames: ['x-api-key', 'authorization'],
|
|
94
|
+
extractBearer: true,
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## API
|
|
99
|
+
|
|
100
|
+
### Creating & Managing Keys
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// Create with plain object
|
|
104
|
+
const { key, record } = await keys.create({
|
|
105
|
+
ownerId: 'user_123',
|
|
106
|
+
name: 'Production Key',
|
|
107
|
+
description: 'Key for production API access',
|
|
108
|
+
scopes: ['read', 'write'],
|
|
109
|
+
tags: ['production', 'api'],
|
|
110
|
+
resources: {
|
|
111
|
+
'project:123': ['read', 'write'],
|
|
112
|
+
'project:456': ['read']
|
|
113
|
+
},
|
|
114
|
+
expiresAt: '2025-12-31',
|
|
115
|
+
enabled: true, // optional, defaults to true
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Create with ResourceBuilder (fluent API)
|
|
119
|
+
import { ResourceBuilder, createResourceBuilder } from 'keypal'
|
|
120
|
+
|
|
121
|
+
const resources = new ResourceBuilder()
|
|
122
|
+
.add('website', 'site123', ['read', 'write'])
|
|
123
|
+
.add('project', 'proj456', ['deploy'])
|
|
124
|
+
.addMany('website', ['site1', 'site2', 'site3'], ['read']) // Same scopes for multiple resources
|
|
125
|
+
.build()
|
|
126
|
+
|
|
127
|
+
const { key: key2, record: record2 } = await keys.create({
|
|
128
|
+
ownerId: 'user_123',
|
|
129
|
+
scopes: ['admin'],
|
|
130
|
+
resources, // Use the built resources object
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// List
|
|
134
|
+
const userKeys = await keys.list('user_123')
|
|
135
|
+
|
|
136
|
+
// Find by tag
|
|
137
|
+
const taggedKeys = await keys.findByTag('production')
|
|
138
|
+
const multiTagKeys = await keys.findByTags(['production', 'api'])
|
|
139
|
+
|
|
140
|
+
// Find by ID or hash
|
|
141
|
+
const keyRecord = await keys.findById(record.id)
|
|
142
|
+
const keyByHash = await keys.findByHash(record.keyHash)
|
|
143
|
+
|
|
144
|
+
// Enable/Disable
|
|
145
|
+
await keys.enable(record.id)
|
|
146
|
+
await keys.disable(record.id)
|
|
147
|
+
|
|
148
|
+
// Rotate (create new key, mark old as revoked)
|
|
149
|
+
const { key: newKey, record: newRecord, oldRecord } = await keys.rotate(record.id, {
|
|
150
|
+
name: 'Updated Key',
|
|
151
|
+
scopes: ['read', 'write', 'admin'],
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Revoke (soft delete - keeps record with revokedAt timestamp)
|
|
155
|
+
await keys.revoke(record.id)
|
|
156
|
+
await keys.revokeAll('user_123')
|
|
157
|
+
|
|
158
|
+
// Update last used
|
|
159
|
+
await keys.updateLastUsed(record.id)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Verifying Keys
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// From headers (automatic detection)
|
|
166
|
+
const result = await keys.verify(request.headers)
|
|
167
|
+
|
|
168
|
+
// From string
|
|
169
|
+
const result = await keys.verify('sk_abc123')
|
|
170
|
+
const result = await keys.verify('Bearer sk_abc123')
|
|
171
|
+
|
|
172
|
+
// With options
|
|
173
|
+
const result = await keys.verify(headers, {
|
|
174
|
+
headerNames: ['x-custom-key'],
|
|
175
|
+
skipCache: true,
|
|
176
|
+
skipTracking: true, // Skip updating lastUsedAt (useful when autoTrackUsage is enabled)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Check result
|
|
180
|
+
if (result.valid) {
|
|
181
|
+
console.log(result.record)
|
|
182
|
+
} else {
|
|
183
|
+
console.log(result.error) // Human-readable error message
|
|
184
|
+
console.log(result.errorCode) // Error code for programmatic handling (see ApiKeyErrorCode)
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Permission Checking
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
// Global scope checks
|
|
192
|
+
if (keys.hasScope(record, 'write')) { /* ... */ }
|
|
193
|
+
if (keys.hasAnyScope(record, ['admin', 'moderator'])) { /* ... */ }
|
|
194
|
+
if (keys.hasAllScopes(record, ['read', 'write'])) { /* ... */ }
|
|
195
|
+
if (keys.isExpired(record)) { /* ... */ }
|
|
196
|
+
|
|
197
|
+
// Resource-specific scope checks
|
|
198
|
+
// Check if key has 'read' scope for a specific resource
|
|
199
|
+
if (keys.checkResourceScope(record, 'website', 'site123', 'read')) { /* ... */ }
|
|
200
|
+
|
|
201
|
+
// Check if key has any of the specified scopes for a resource
|
|
202
|
+
if (keys.checkResourceAnyScope(record, 'website', 'site123', ['admin', 'write'])) { /* ... */ }
|
|
203
|
+
|
|
204
|
+
// Check if key has all specified scopes for a resource (checks both global and resource scopes)
|
|
205
|
+
if (keys.checkResourceAllScopes(record, 'website', 'site123', ['read', 'write'])) { /* ... */ }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### ResourceBuilder (Fluent API)
|
|
209
|
+
|
|
210
|
+
Build resource-specific scopes with a clean, chainable API:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import { ResourceBuilder, createResourceBuilder } from 'keypal'
|
|
214
|
+
|
|
215
|
+
// Basic usage
|
|
216
|
+
const resources = new ResourceBuilder()
|
|
217
|
+
.add('website', 'site123', ['read', 'write'])
|
|
218
|
+
.add('project', 'proj456', ['deploy'])
|
|
219
|
+
.build()
|
|
220
|
+
|
|
221
|
+
// Add scopes to multiple resources at once
|
|
222
|
+
const resources2 = new ResourceBuilder()
|
|
223
|
+
.addMany('website', ['site1', 'site2', 'site3'], ['read'])
|
|
224
|
+
.add('project', 'proj1', ['deploy', 'rollback'])
|
|
225
|
+
.build()
|
|
226
|
+
|
|
227
|
+
// Add single scopes
|
|
228
|
+
const resources3 = new ResourceBuilder()
|
|
229
|
+
.addOne('website', 'site123', 'read')
|
|
230
|
+
.addOne('website', 'site123', 'write')
|
|
231
|
+
.build()
|
|
232
|
+
|
|
233
|
+
// Modify existing resources
|
|
234
|
+
const builder = new ResourceBuilder()
|
|
235
|
+
.add('website', 'site123', ['read', 'write'])
|
|
236
|
+
.add('project', 'proj456', ['deploy'])
|
|
237
|
+
|
|
238
|
+
// Check if resource exists
|
|
239
|
+
if (builder.has('website', 'site123')) {
|
|
240
|
+
const scopes = builder.get('website', 'site123')
|
|
241
|
+
console.log(scopes) // ['read', 'write']
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Remove specific scopes
|
|
245
|
+
builder.removeScopes('website', 'site123', ['write'])
|
|
246
|
+
|
|
247
|
+
// Remove entire resource
|
|
248
|
+
builder.remove('project', 'proj456')
|
|
249
|
+
|
|
250
|
+
// Build final result
|
|
251
|
+
const finalResources = builder.build()
|
|
252
|
+
|
|
253
|
+
// Start from existing resources (useful for updates)
|
|
254
|
+
const existingResources = {
|
|
255
|
+
'website:site123': ['read'],
|
|
256
|
+
'project:proj456': ['deploy']
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const updated = ResourceBuilder.from(existingResources)
|
|
260
|
+
.add('website', 'site123', ['write']) // Merges with existing
|
|
261
|
+
.add('team', 'team789', ['admin'])
|
|
262
|
+
.build()
|
|
263
|
+
|
|
264
|
+
// Use with createKeys
|
|
265
|
+
await keys.create({
|
|
266
|
+
ownerId: 'user_123',
|
|
267
|
+
scopes: ['admin'],
|
|
268
|
+
resources: updated
|
|
269
|
+
})
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**ResourceBuilder Methods:**
|
|
273
|
+
- `add(resourceType, resourceId, scopes)` - Add scopes to a resource (merges if exists)
|
|
274
|
+
- `addOne(resourceType, resourceId, scope)` - Add a single scope
|
|
275
|
+
- `addMany(resourceType, resourceIds, scopes)` - Add same scopes to multiple resources
|
|
276
|
+
- `remove(resourceType, resourceId)` - Remove entire resource
|
|
277
|
+
- `removeScopes(resourceType, resourceId, scopes)` - Remove specific scopes
|
|
278
|
+
- `has(resourceType, resourceId)` - Check if resource exists
|
|
279
|
+
- `get(resourceType, resourceId)` - Get scopes for a resource
|
|
280
|
+
- `clear()` - Clear all resources
|
|
281
|
+
- `build()` - Build and return the resources object
|
|
282
|
+
- `ResourceBuilder.from(resources)` - Create from existing resources object
|
|
283
|
+
|
|
284
|
+
### Usage Tracking
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// Enable automatic tracking in config
|
|
288
|
+
const keys = createKeys({
|
|
289
|
+
autoTrackUsage: true, // Automatically updates lastUsedAt on verify
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// Manually update (always available)
|
|
293
|
+
await keys.updateLastUsed(record.id)
|
|
294
|
+
|
|
295
|
+
// Skip tracking for specific requests
|
|
296
|
+
const result = await keys.verify(headers, { skipTracking: true })
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Audit Logging
|
|
300
|
+
|
|
301
|
+
Track all key operations with context about who performed each action:
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
// Enable audit logging
|
|
305
|
+
const keys = createKeys({
|
|
306
|
+
auditLogs: true,
|
|
307
|
+
auditContext: {
|
|
308
|
+
// Default context merged into all logs
|
|
309
|
+
metadata: { environment: 'production' }
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// Actions are automatically logged with optional context
|
|
314
|
+
await keys.create({
|
|
315
|
+
ownerId: 'user_123',
|
|
316
|
+
scopes: ['read']
|
|
317
|
+
}, {
|
|
318
|
+
userId: 'admin_456',
|
|
319
|
+
ip: '192.168.1.1',
|
|
320
|
+
metadata: { reason: 'New customer onboarding' }
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
await keys.revoke('key_123', {
|
|
324
|
+
userId: 'admin_789',
|
|
325
|
+
metadata: { reason: 'Security breach' }
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Query logs
|
|
329
|
+
const logs = await keys.getLogs({
|
|
330
|
+
keyId: 'key_123',
|
|
331
|
+
action: 'revoked',
|
|
332
|
+
startDate: '2025-01-01',
|
|
333
|
+
limit: 100
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// Count logs
|
|
337
|
+
const count = await keys.countLogs({ action: 'created' })
|
|
338
|
+
|
|
339
|
+
// Get statistics
|
|
340
|
+
const stats = await keys.getLogStats('user_123')
|
|
341
|
+
console.log(stats.total)
|
|
342
|
+
console.log(stats.byAction.created)
|
|
343
|
+
console.log(stats.lastActivity)
|
|
344
|
+
|
|
345
|
+
// Clean up old logs
|
|
346
|
+
const deleted = await keys.deleteLogs({
|
|
347
|
+
endDate: '2024-01-01'
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// Clear logs for a specific key
|
|
351
|
+
await keys.clearLogs('key_123')
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
**Log Entry Structure:**
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
{
|
|
358
|
+
id: 'log_xyz',
|
|
359
|
+
action: 'created' | 'revoked' | 'rotated' | 'enabled' | 'disabled',
|
|
360
|
+
keyId: 'key_123',
|
|
361
|
+
ownerId: 'user_456',
|
|
362
|
+
timestamp: '2025-10-25T12:00:00.000Z',
|
|
363
|
+
data: {
|
|
364
|
+
userId: 'admin_789',
|
|
365
|
+
ip: '192.168.1.1',
|
|
366
|
+
metadata: { reason: 'Security breach' }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Helper Methods
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
keys.hasKey(headers) // boolean - check if headers contain an API key
|
|
375
|
+
keys.extractKey(headers) // string | null - extract key from headers
|
|
376
|
+
keys.generateKey() // string - generate a new key (without saving)
|
|
377
|
+
keys.hashKey(key) // string - hash a key (useful for custom storage)
|
|
378
|
+
keys.invalidateCache(keyHash) // Promise<void> - manually invalidate cached key
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Standalone Utility Functions
|
|
382
|
+
|
|
383
|
+
You can also use these functions without a manager instance:
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
import {
|
|
387
|
+
isExpired,
|
|
388
|
+
getExpirationTime,
|
|
389
|
+
extractKeyFromHeaders,
|
|
390
|
+
hasApiKey,
|
|
391
|
+
hasScope,
|
|
392
|
+
hasAnyScope,
|
|
393
|
+
hasAllScopes,
|
|
394
|
+
ApiKeyErrorCode,
|
|
395
|
+
createApiKeyError
|
|
396
|
+
} from 'keypal'
|
|
397
|
+
|
|
398
|
+
// Check expiration
|
|
399
|
+
const expired = isExpired('2025-12-31T00:00:00.000Z')
|
|
400
|
+
const expirationDate = getExpirationTime('2025-12-31T00:00:00.000Z') // Date | null
|
|
401
|
+
|
|
402
|
+
// Extract key from headers
|
|
403
|
+
const key = extractKeyFromHeaders(request.headers, {
|
|
404
|
+
headerNames: ['x-api-key'],
|
|
405
|
+
extractBearer: true
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// Check if headers have API key
|
|
409
|
+
if (hasApiKey(request.headers)) {
|
|
410
|
+
const key = extractKeyFromHeaders(request.headers)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Check scopes (for plain scope arrays, not records)
|
|
414
|
+
const hasWrite = hasScope(['read', 'write'], 'write')
|
|
415
|
+
const hasAny = hasAnyScope(['read', 'write'], ['admin', 'write'])
|
|
416
|
+
const hasAll = hasAllScopes(['read', 'write'], ['read', 'write'])
|
|
417
|
+
|
|
418
|
+
// Error handling
|
|
419
|
+
if (!result.valid) {
|
|
420
|
+
switch (result.errorCode) {
|
|
421
|
+
case ApiKeyErrorCode.EXPIRED:
|
|
422
|
+
// Handle expired key
|
|
423
|
+
break
|
|
424
|
+
case ApiKeyErrorCode.REVOKED:
|
|
425
|
+
// Handle revoked key
|
|
426
|
+
break
|
|
427
|
+
case ApiKeyErrorCode.DISABLED:
|
|
428
|
+
// Handle disabled key
|
|
429
|
+
break
|
|
430
|
+
// ... other error codes
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Create custom errors
|
|
435
|
+
const error = createApiKeyError(ApiKeyErrorCode.INVALID_KEY, {
|
|
436
|
+
attemptedKey: 'sk_abc123'
|
|
437
|
+
})
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**Available Error Codes:**
|
|
441
|
+
- `MISSING_KEY` - No API key provided
|
|
442
|
+
- `INVALID_FORMAT` - API key format is invalid
|
|
443
|
+
- `INVALID_KEY` - API key does not exist
|
|
444
|
+
- `EXPIRED` - API key has expired
|
|
445
|
+
- `REVOKED` - API key has been revoked
|
|
446
|
+
- `DISABLED` - API key is disabled
|
|
447
|
+
- `STORAGE_ERROR` - Storage operation failed
|
|
448
|
+
- `CACHE_ERROR` - Cache operation failed
|
|
449
|
+
- `ALREADY_REVOKED` - Key is already revoked
|
|
450
|
+
- `ALREADY_ENABLED` - Key is already enabled
|
|
451
|
+
- `ALREADY_DISABLED` - Key is already disabled
|
|
452
|
+
- `CANNOT_MODIFY_REVOKED` - Cannot modify revoked key
|
|
453
|
+
- `KEY_NOT_FOUND` - API key not found
|
|
454
|
+
- `AUDIT_LOGGING_DISABLED` - Audit logging not enabled
|
|
455
|
+
- `STORAGE_NOT_SUPPORTED` - Storage doesn't support operation
|
|
456
|
+
|
|
457
|
+
## Storage Examples
|
|
458
|
+
|
|
459
|
+
### Memory (Default)
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
const keys = createKeys({ prefix: 'sk_' })
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Redis
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
import Redis from 'ioredis'
|
|
469
|
+
|
|
470
|
+
const redis = new Redis()
|
|
471
|
+
|
|
472
|
+
const keys = createKeys({
|
|
473
|
+
prefix: 'sk_',
|
|
474
|
+
storage: 'redis',
|
|
475
|
+
cache: 'redis',
|
|
476
|
+
redis,
|
|
477
|
+
})
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### Drizzle ORM
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
484
|
+
import { Pool } from 'pg'
|
|
485
|
+
import { DrizzleStore } from 'keypal/drizzle'
|
|
486
|
+
import { apikey } from 'keypal/drizzle/schema'
|
|
487
|
+
import { createKeys } from 'keypal'
|
|
488
|
+
|
|
489
|
+
const pool = new Pool({
|
|
490
|
+
connectionString: process.env.DATABASE_URL
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
const db = drizzle(pool, { schema: { apikey } })
|
|
494
|
+
|
|
495
|
+
const keys = createKeys({
|
|
496
|
+
prefix: 'sk_prod_',
|
|
497
|
+
storage: new DrizzleStore({ db, table: apikey }),
|
|
498
|
+
cache: true,
|
|
499
|
+
})
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
**Setup Database Schema:**
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
// src/drizzle/schema.ts
|
|
506
|
+
import { index, jsonb, pgTable, text, unique } from 'drizzle-orm/pg-core'
|
|
507
|
+
|
|
508
|
+
export const apikey = pgTable(
|
|
509
|
+
'apikey',
|
|
510
|
+
{
|
|
511
|
+
id: text().primaryKey().notNull(),
|
|
512
|
+
keyHash: text('key_hash').notNull(),
|
|
513
|
+
metadata: jsonb('metadata').notNull(),
|
|
514
|
+
},
|
|
515
|
+
(table) => [
|
|
516
|
+
index('apikey_key_hash_idx').on(table.keyHash),
|
|
517
|
+
unique('apikey_key_hash_unique').on(table.keyHash),
|
|
518
|
+
]
|
|
519
|
+
)
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
**Generate migrations:**
|
|
523
|
+
|
|
524
|
+
```bash
|
|
525
|
+
bun run db:generate
|
|
526
|
+
bun run db:push
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
**Use Drizzle Studio:**
|
|
530
|
+
|
|
531
|
+
```bash
|
|
532
|
+
bun run studio
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### Prisma
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
import { PrismaClient } from '@prisma/client'
|
|
539
|
+
import { PrismaStore } from 'keypal/prisma'
|
|
540
|
+
import { createKeys } from 'keypal'
|
|
541
|
+
|
|
542
|
+
const prisma = new PrismaClient()
|
|
543
|
+
|
|
544
|
+
const keys = createKeys({
|
|
545
|
+
prefix: 'sk_prod_',
|
|
546
|
+
storage: new PrismaStore({ prisma, model: 'apiKey' }),
|
|
547
|
+
cache: true,
|
|
548
|
+
})
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
**Setup Prisma Schema:**
|
|
552
|
+
|
|
553
|
+
```prisma
|
|
554
|
+
model ApiKey {
|
|
555
|
+
id String @id @default(cuid())
|
|
556
|
+
keyHash String @unique
|
|
557
|
+
metadata Json
|
|
558
|
+
|
|
559
|
+
@@index([keyHash])
|
|
560
|
+
@@map("api_keys")
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Kysely
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
import { Kysely, PostgresDialect } from 'kysely'
|
|
568
|
+
import { Pool } from 'pg'
|
|
569
|
+
import { KyselyStore } from 'keypal/kysely'
|
|
570
|
+
import { createKeys } from 'keypal'
|
|
571
|
+
|
|
572
|
+
const db = new Kysely({
|
|
573
|
+
dialect: new PostgresDialect({
|
|
574
|
+
pool: new Pool({
|
|
575
|
+
connectionString: process.env.DATABASE_URL
|
|
576
|
+
})
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
const keys = createKeys({
|
|
581
|
+
prefix: 'sk_prod_',
|
|
582
|
+
storage: new KyselyStore({ db, tableName: 'api_keys' }),
|
|
583
|
+
cache: true,
|
|
584
|
+
})
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
**Setup Database Schema (PostgreSQL):**
|
|
588
|
+
|
|
589
|
+
```sql
|
|
590
|
+
CREATE TABLE api_keys (
|
|
591
|
+
id TEXT PRIMARY KEY,
|
|
592
|
+
"keyHash" TEXT UNIQUE NOT NULL,
|
|
593
|
+
metadata JSONB NOT NULL
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
CREATE INDEX api_keys_key_hash_idx ON api_keys("keyHash");
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
> **Note**: The column uses camelCase (`keyHash`) by default. For MySQL, omit the quotes around `keyHash`.
|
|
600
|
+
|
|
601
|
+
### Custom Storage
|
|
602
|
+
|
|
603
|
+
```typescript
|
|
604
|
+
import { type Storage } from 'keypal'
|
|
605
|
+
|
|
606
|
+
const customStorage: Storage = {
|
|
607
|
+
save: async (record) => { /* ... */ },
|
|
608
|
+
findByHash: async (keyHash) => { /* ... */ },
|
|
609
|
+
findById: async (id) => { /* ... */ },
|
|
610
|
+
findByOwner: async (ownerId) => { /* ... */ },
|
|
611
|
+
findByTag: async (tag, ownerId) => { /* ... */ },
|
|
612
|
+
findByTags: async (tags, ownerId) => { /* ... */ },
|
|
613
|
+
updateMetadata: async (id, metadata) => { /* ... */ },
|
|
614
|
+
delete: async (id) => { /* ... */ },
|
|
615
|
+
deleteByOwner: async (ownerId) => { /* ... */ },
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const keys = createKeys({
|
|
619
|
+
storage: customStorage,
|
|
620
|
+
})
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
## Error Handling Best Practices
|
|
624
|
+
|
|
625
|
+
### Comprehensive Error Handling
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
import { createKeys, ApiKeyErrorCode } from 'keypal'
|
|
629
|
+
|
|
630
|
+
const keys = createKeys({
|
|
631
|
+
prefix: 'sk_',
|
|
632
|
+
storage: 'redis',
|
|
633
|
+
redis,
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
// Verify with comprehensive error handling
|
|
637
|
+
const result = await keys.verify(request.headers)
|
|
638
|
+
|
|
639
|
+
if (!result.valid) {
|
|
640
|
+
switch (result.errorCode) {
|
|
641
|
+
case ApiKeyErrorCode.MISSING_KEY:
|
|
642
|
+
return { error: 'API key is required', statusCode: 401 }
|
|
643
|
+
|
|
644
|
+
case ApiKeyErrorCode.INVALID_FORMAT:
|
|
645
|
+
return { error: 'Invalid API key format', statusCode: 401 }
|
|
646
|
+
|
|
647
|
+
case ApiKeyErrorCode.INVALID_KEY:
|
|
648
|
+
return { error: 'Invalid API key', statusCode: 401 }
|
|
649
|
+
|
|
650
|
+
case ApiKeyErrorCode.EXPIRED:
|
|
651
|
+
return { error: 'API key has expired', statusCode: 401 }
|
|
652
|
+
|
|
653
|
+
case ApiKeyErrorCode.REVOKED:
|
|
654
|
+
return { error: 'API key has been revoked', statusCode: 401 }
|
|
655
|
+
|
|
656
|
+
case ApiKeyErrorCode.DISABLED:
|
|
657
|
+
return { error: 'API key is disabled', statusCode: 403 }
|
|
658
|
+
|
|
659
|
+
default:
|
|
660
|
+
return { error: 'Authentication failed', statusCode: 401 }
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Key is valid, proceed with request
|
|
665
|
+
console.log('Authenticated user:', result.record.metadata.ownerId)
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### Handling Storage Errors
|
|
669
|
+
|
|
670
|
+
```typescript
|
|
671
|
+
import { createKeys, createApiKeyError, ApiKeyErrorCode } from 'keypal'
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
// Create a key
|
|
675
|
+
const { key, record } = await keys.create({
|
|
676
|
+
ownerId: 'user_123',
|
|
677
|
+
scopes: ['read', 'write'],
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
return { success: true, key, keyId: record.id }
|
|
681
|
+
} catch (error) {
|
|
682
|
+
console.error('Failed to create API key:', error)
|
|
683
|
+
|
|
684
|
+
// Handle specific error types
|
|
685
|
+
if (error instanceof Error) {
|
|
686
|
+
if (error.message.includes('duplicate')) {
|
|
687
|
+
return { success: false, error: 'Duplicate key detected' }
|
|
688
|
+
}
|
|
689
|
+
if (error.message.includes('connection')) {
|
|
690
|
+
return { success: false, error: 'Database connection failed' }
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return { success: false, error: 'Failed to create API key' }
|
|
695
|
+
}
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
### Handling Key Operations
|
|
699
|
+
|
|
700
|
+
```typescript
|
|
701
|
+
// Revoke with error handling
|
|
702
|
+
try {
|
|
703
|
+
await keys.revoke(keyId, {
|
|
704
|
+
userId: 'admin_123',
|
|
705
|
+
metadata: { reason: 'User request' }
|
|
706
|
+
})
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (error.code === ApiKeyErrorCode.KEY_NOT_FOUND) {
|
|
709
|
+
return { error: 'Key not found', statusCode: 404 }
|
|
710
|
+
}
|
|
711
|
+
if (error.code === ApiKeyErrorCode.ALREADY_REVOKED) {
|
|
712
|
+
return { error: 'Key is already revoked', statusCode: 400 }
|
|
713
|
+
}
|
|
714
|
+
throw error // Re-throw unexpected errors
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Enable/Disable with error handling
|
|
718
|
+
try {
|
|
719
|
+
await keys.enable(keyId)
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (error.code === ApiKeyErrorCode.KEY_NOT_FOUND) {
|
|
722
|
+
return { error: 'Key not found', statusCode: 404 }
|
|
723
|
+
}
|
|
724
|
+
if (error.code === ApiKeyErrorCode.ALREADY_ENABLED) {
|
|
725
|
+
return { message: 'Key was already enabled', statusCode: 200 }
|
|
726
|
+
}
|
|
727
|
+
if (error.code === ApiKeyErrorCode.CANNOT_MODIFY_REVOKED) {
|
|
728
|
+
return { error: 'Cannot enable a revoked key', statusCode: 400 }
|
|
729
|
+
}
|
|
730
|
+
throw error
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Rotate with error handling
|
|
734
|
+
try {
|
|
735
|
+
const { key: newKey, record, oldRecord } = await keys.rotate(keyId, {
|
|
736
|
+
scopes: ['read', 'write', 'admin'],
|
|
737
|
+
})
|
|
738
|
+
return { success: true, key: newKey, keyId: record.id }
|
|
739
|
+
} catch (error) {
|
|
740
|
+
if (error.code === ApiKeyErrorCode.KEY_NOT_FOUND) {
|
|
741
|
+
return { error: 'Key not found', statusCode: 404 }
|
|
742
|
+
}
|
|
743
|
+
if (error.code === ApiKeyErrorCode.CANNOT_MODIFY_REVOKED) {
|
|
744
|
+
return { error: 'Cannot rotate a revoked key', statusCode: 400 }
|
|
745
|
+
}
|
|
746
|
+
throw error
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### Drizzle Storage Error Handling
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
import { DrizzleStore } from 'keypal/drizzle'
|
|
754
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
755
|
+
import { Pool } from 'pg'
|
|
756
|
+
import { apikey } from 'keypal/drizzle/schema'
|
|
757
|
+
|
|
758
|
+
// Initialize with connection error handling
|
|
759
|
+
let pool: Pool
|
|
760
|
+
let store: DrizzleStore
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
pool = new Pool({
|
|
764
|
+
connectionString: process.env.DATABASE_URL,
|
|
765
|
+
// Connection pool settings
|
|
766
|
+
max: 20,
|
|
767
|
+
idleTimeoutMillis: 30000,
|
|
768
|
+
connectionTimeoutMillis: 2000,
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
// Test connection
|
|
772
|
+
await pool.query('SELECT 1')
|
|
773
|
+
|
|
774
|
+
const db = drizzle(pool, { schema: { apikey } })
|
|
775
|
+
store = new DrizzleStore({ db, table: apikey })
|
|
776
|
+
|
|
777
|
+
console.log('Database connection established')
|
|
778
|
+
} catch (error) {
|
|
779
|
+
console.error('Failed to connect to database:', error)
|
|
780
|
+
throw new Error('Database initialization failed')
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const keys = createKeys({
|
|
784
|
+
prefix: 'sk_',
|
|
785
|
+
storage: store,
|
|
786
|
+
cache: true,
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
// Update metadata with error handling
|
|
790
|
+
try {
|
|
791
|
+
await store.updateMetadata(keyId, {
|
|
792
|
+
name: 'Updated Key',
|
|
793
|
+
scopes: ['admin'],
|
|
794
|
+
})
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (error.message.includes('not found')) {
|
|
797
|
+
return { error: 'Key not found', statusCode: 404 }
|
|
798
|
+
}
|
|
799
|
+
console.error('Failed to update key metadata:', error)
|
|
800
|
+
throw error
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Handle duplicate key errors
|
|
804
|
+
try {
|
|
805
|
+
await keys.create({
|
|
806
|
+
ownerId: 'user_123',
|
|
807
|
+
name: 'My Key',
|
|
808
|
+
})
|
|
809
|
+
} catch (error) {
|
|
810
|
+
// PostgreSQL duplicate key error
|
|
811
|
+
if (error.code === '23505') {
|
|
812
|
+
return { error: 'Duplicate key detected', statusCode: 409 }
|
|
813
|
+
}
|
|
814
|
+
throw error
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Graceful shutdown
|
|
818
|
+
process.on('SIGTERM', async () => {
|
|
819
|
+
await pool.end()
|
|
820
|
+
console.log('Database connection closed')
|
|
821
|
+
})
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
## Framework Example (Hono)
|
|
825
|
+
|
|
826
|
+
```typescript
|
|
827
|
+
import { Hono } from 'hono'
|
|
828
|
+
import { createKeys, ApiKeyErrorCode } from 'keypal'
|
|
829
|
+
import Redis from 'ioredis'
|
|
830
|
+
|
|
831
|
+
const redis = new Redis()
|
|
832
|
+
|
|
833
|
+
const keys = createKeys({
|
|
834
|
+
prefix: 'sk_',
|
|
835
|
+
storage: 'redis',
|
|
836
|
+
cache: 'redis',
|
|
837
|
+
redis,
|
|
838
|
+
auditLogs: true,
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
const app = new Hono()
|
|
842
|
+
|
|
843
|
+
// Authentication middleware with comprehensive error handling
|
|
844
|
+
app.use('/api/*', async (c, next) => {
|
|
845
|
+
const result = await keys.verify(c.req.raw.headers)
|
|
846
|
+
|
|
847
|
+
if (!result.valid) {
|
|
848
|
+
// Log failed authentication attempts
|
|
849
|
+
console.warn('Authentication failed:', {
|
|
850
|
+
error: result.error,
|
|
851
|
+
errorCode: result.errorCode,
|
|
852
|
+
path: c.req.path,
|
|
853
|
+
ip: c.req.header('x-forwarded-for'),
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
// Return appropriate error response
|
|
857
|
+
const statusCode = result.errorCode === ApiKeyErrorCode.DISABLED ? 403 : 401
|
|
858
|
+
return c.json({ error: result.error, code: result.errorCode }, statusCode)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Store record in context for downstream handlers
|
|
862
|
+
c.set('apiKey', result.record)
|
|
863
|
+
|
|
864
|
+
// Track usage (fire and forget)
|
|
865
|
+
if (result.record) {
|
|
866
|
+
keys.updateLastUsed(result.record.id).catch((err) => {
|
|
867
|
+
console.error('Failed to update lastUsedAt:', err)
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
await next()
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
// Protected route with scope check
|
|
875
|
+
app.get('/api/data', async (c) => {
|
|
876
|
+
const record = c.get('apiKey')
|
|
877
|
+
|
|
878
|
+
if (!keys.hasScope(record, 'read')) {
|
|
879
|
+
return c.json({ error: 'Insufficient permissions' }, 403)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return c.json({ data: 'sensitive data' })
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
// Resource-specific scope check
|
|
886
|
+
app.get('/api/projects/:id', async (c) => {
|
|
887
|
+
const record = c.get('apiKey')
|
|
888
|
+
const projectId = c.req.param('id')
|
|
889
|
+
|
|
890
|
+
// Check if key has read scope for this specific project
|
|
891
|
+
if (!keys.checkResourceScope(record, 'project', projectId, 'read')) {
|
|
892
|
+
return c.json({ error: 'No access to this project' }, 403)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return c.json({ project: { id: projectId } })
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
// Create API key endpoint
|
|
899
|
+
app.post('/api/keys', async (c) => {
|
|
900
|
+
const record = c.get('apiKey')
|
|
901
|
+
|
|
902
|
+
// Only admins can create keys
|
|
903
|
+
if (!keys.hasScope(record, 'admin')) {
|
|
904
|
+
return c.json({ error: 'Admin permission required' }, 403)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
try {
|
|
908
|
+
const body = await c.req.json()
|
|
909
|
+
|
|
910
|
+
const { key, record: newRecord } = await keys.create({
|
|
911
|
+
ownerId: body.ownerId,
|
|
912
|
+
name: body.name,
|
|
913
|
+
scopes: body.scopes,
|
|
914
|
+
expiresAt: body.expiresAt,
|
|
915
|
+
}, {
|
|
916
|
+
userId: record.metadata.ownerId,
|
|
917
|
+
ip: c.req.header('x-forwarded-for'),
|
|
918
|
+
metadata: { action: 'api_create' },
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
return c.json({
|
|
922
|
+
success: true,
|
|
923
|
+
key, // Only returned once!
|
|
924
|
+
keyId: newRecord.id
|
|
925
|
+
})
|
|
926
|
+
} catch (error) {
|
|
927
|
+
console.error('Failed to create key:', error)
|
|
928
|
+
return c.json({ error: 'Failed to create key' }, 500)
|
|
929
|
+
}
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
// Revoke API key endpoint
|
|
933
|
+
app.delete('/api/keys/:id', async (c) => {
|
|
934
|
+
const record = c.get('apiKey')
|
|
935
|
+
const keyId = c.req.param('id')
|
|
936
|
+
|
|
937
|
+
try {
|
|
938
|
+
// Verify ownership or admin permission
|
|
939
|
+
const keyToRevoke = await keys.findById(keyId)
|
|
940
|
+
|
|
941
|
+
if (!keyToRevoke) {
|
|
942
|
+
return c.json({ error: 'Key not found' }, 404)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const isOwner = keyToRevoke.metadata.ownerId === record.metadata.ownerId
|
|
946
|
+
const isAdmin = keys.hasScope(record, 'admin')
|
|
947
|
+
|
|
948
|
+
if (!isOwner && !isAdmin) {
|
|
949
|
+
return c.json({ error: 'Not authorized' }, 403)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
await keys.revoke(keyId, {
|
|
953
|
+
userId: record.metadata.ownerId,
|
|
954
|
+
ip: c.req.header('x-forwarded-for'),
|
|
955
|
+
metadata: { via: 'api' },
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
return c.json({ success: true })
|
|
959
|
+
} catch (error) {
|
|
960
|
+
if (error.code === ApiKeyErrorCode.KEY_NOT_FOUND) {
|
|
961
|
+
return c.json({ error: 'Key not found' }, 404)
|
|
962
|
+
}
|
|
963
|
+
if (error.code === ApiKeyErrorCode.ALREADY_REVOKED) {
|
|
964
|
+
return c.json({ error: 'Key is already revoked' }, 400)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
console.error('Failed to revoke key:', error)
|
|
968
|
+
return c.json({ error: 'Failed to revoke key' }, 500)
|
|
969
|
+
}
|
|
970
|
+
})
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
## Security Best Practices
|
|
974
|
+
|
|
975
|
+
1. **Use a salt in production**:
|
|
976
|
+
```typescript
|
|
977
|
+
const keys = createKeys({
|
|
978
|
+
salt: process.env.API_KEY_SALT,
|
|
979
|
+
algorithm: 'sha512',
|
|
980
|
+
})
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
2. **Set expiration dates**: Don't create keys that never expire
|
|
984
|
+
|
|
985
|
+
3. **Use scopes**: Implement least-privilege access
|
|
986
|
+
|
|
987
|
+
4. **Enable caching**: Reduce database load in production
|
|
988
|
+
|
|
989
|
+
5. **Use HTTPS**: Always use HTTPS to prevent key interception
|
|
990
|
+
|
|
991
|
+
6. **Monitor usage**: Track `lastUsedAt` to identify unused keys
|
|
992
|
+
|
|
993
|
+
7. **Rotate keys**: Implement regular key rotation policies
|
|
994
|
+
```typescript
|
|
995
|
+
// Rotate keys periodically
|
|
996
|
+
const { key: newKey } = await keys.rotate(oldRecord.id)
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
8. **Use soft revocation**: Revoked keys are kept with `revokedAt` timestamp for audit trails (Redis TTL: 7 days, Drizzle: forever)
|
|
1000
|
+
|
|
1001
|
+
9. **Enable/Disable rather than revoke**: Temporarily disable keys instead of revoking them
|
|
1002
|
+
|
|
1003
|
+
## TypeScript Types
|
|
1004
|
+
|
|
1005
|
+
```typescript
|
|
1006
|
+
interface ApiKeyRecord {
|
|
1007
|
+
id: string
|
|
1008
|
+
keyHash: string
|
|
1009
|
+
metadata: ApiKeyMetadata
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
interface ApiKeyMetadata {
|
|
1013
|
+
ownerId: string
|
|
1014
|
+
name?: string
|
|
1015
|
+
description?: string
|
|
1016
|
+
scopes?: string[]
|
|
1017
|
+
resources?: Record<string, string[]> // Resource-specific scopes (e.g., { "project:123": ["read", "write"] })
|
|
1018
|
+
tags?: string[]
|
|
1019
|
+
expiresAt: string | null
|
|
1020
|
+
createdAt?: string
|
|
1021
|
+
lastUsedAt?: string
|
|
1022
|
+
enabled?: boolean
|
|
1023
|
+
revokedAt?: string | null
|
|
1024
|
+
rotatedTo?: string | null
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
interface VerifyResult {
|
|
1028
|
+
valid: boolean
|
|
1029
|
+
record?: ApiKeyRecord
|
|
1030
|
+
error?: string
|
|
1031
|
+
errorCode?: ApiKeyErrorCode
|
|
1032
|
+
}
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
## License
|
|
1036
|
+
|
|
1037
|
+
MIT
|