diff --git a/docs/api-keys.md b/docs/api-keys.md index 98af5db..207f945 100644 --- a/docs/api-keys.md +++ b/docs/api-keys.md @@ -1,6 +1,6 @@ # API Key Authentication -Credence API supports API key authentication for programmatic access. +Credence API supports API key authentication for programmatic access with fine-grained scope-based access control. ## Key Format @@ -89,8 +89,9 @@ Keys issued before the granular scope model was introduced carry one of two lega ### Issue a key ```http -POST /api/keys +POST /api/api-keys Content-Type: application/json +Authorization: Bearer { "ownerId": "user_abc", @@ -118,7 +119,8 @@ Response (201 — the raw key is **only returned here**): ### List keys for an owner ```http -GET /api/keys?ownerId=user_abc +GET /api/api-keys/:ownerId +Authorization: Bearer ``` Response omits the raw key and the stored hash: @@ -144,7 +146,8 @@ Response omits the raw key and the stored hash: Revokes the current key and issues a new one with the same scopes and tier: ```http -POST /api/keys/:id/rotate +POST /api/api-keys/:id/rotate +Authorization: Bearer ``` Response: same shape as key creation (201), including the new raw key. @@ -152,7 +155,8 @@ Response: same shape as key creation (201), including the new raw key. ### Revoke a key ```http -DELETE /api/keys/:id +DELETE /api/api-keys/:id +Authorization: Bearer ``` Response: **204 No Content**. Subsequent requests using the revoked key receive **401 Unauthorized**. @@ -162,6 +166,7 @@ Response: **204 No Content**. Subsequent requests using the revoked key receive - Keys are stored as **SHA-256 hashes** — the raw key is never persisted and is shown exactly once. - Issue keys with the **minimum scopes required** for the integration. Do not use `enterprise` unless all operations are needed. - Rotate keys periodically; compromised keys can be revoked at any time. +- All key operations (create, revoke, rotate) are logged to the audit log. - Rate limits are enforced per tier (integration at the infrastructure layer, e.g. via a reverse proxy or Redis-based limiter). - The middleware is **deny-by-default**: if a key does not carry the required scope, the request is rejected with `403 Forbidden` before reaching the handler. diff --git a/src/db/repositories/apiKeysRepository.ts b/src/db/repositories/apiKeysRepository.ts new file mode 100644 index 0000000..c7f59a2 --- /dev/null +++ b/src/db/repositories/apiKeysRepository.ts @@ -0,0 +1,125 @@ +import type { Queryable } from './queryable.js' +import type { StoredApiKey } from '../../services/apiKeys.js' +import type { KeyScope } from '../../services/apiKeys.js' + +/** + * Repository for API key persistence operations + */ +export class ApiKeysRepository { + constructor(private readonly db: Queryable) {} + + /** + * Create a new API key in the database + */ + async createApiKey(keyData: Omit): Promise { + const result = await this.db.query( + `INSERT INTO api_keys (hashed_key, prefix, scopes, tier, owner_id, created_at, last_used_at, active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, hashed_key, prefix, scopes, tier, owner_id, created_at, last_used_at, active`, + [ + keyData.hashedKey, + keyData.prefix, + keyData.scopes, + keyData.tier, + keyData.ownerId, + keyData.createdAt, + keyData.lastUsedAt, + keyData.active, + ] + ) + + const row = result.rows[0] + return { + id: row.id.toString(), + hashedKey: row.hashed_key, + prefix: row.prefix, + scopes: row.scopes as KeyScope[], + tier: row.tier, + ownerId: row.owner_id, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + active: row.active, + } + } + + /** + * Find an API key by its hashed value and prefix + */ + async findByHashAndPrefix(hashedKey: string, prefix: string): Promise { + const result = await this.db.query( + `SELECT id, hashed_key, prefix, scopes, tier, owner_id, created_at, last_used_at, active + FROM api_keys + WHERE hashed_key = $1 AND prefix = $2 AND active = true`, + [hashedKey, prefix] + ) + + if (result.rows.length === 0) { + return null + } + + const row = result.rows[0] + return { + id: row.id.toString(), + hashedKey: row.hashed_key, + prefix: row.prefix, + scopes: row.scopes as KeyScope[], + tier: row.tier, + ownerId: row.owner_id, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + active: row.active, + } + } + + /** + * Update the last_used_at timestamp for a key + */ + async updateLastUsedAt(id: string): Promise { + await this.db.query( + `UPDATE api_keys SET last_used_at = current_timestamp WHERE id = $1`, + [id] + ) + } + + /** + * Revoke an API key by setting active to false + */ + async revokeApiKey(id: string): Promise { + const result = await this.db.query( + `UPDATE api_keys SET active = false WHERE id = $1 RETURNING id`, + [id] + ) + return result.rows.length > 0 + } + + /** + * List all API keys for an owner + */ + async listByOwner(ownerId: string): Promise[]> { + const result = await this.db.query( + `SELECT id, prefix, scopes, tier, owner_id, created_at, last_used_at, active + FROM api_keys + WHERE owner_id = $1 + ORDER BY created_at DESC`, + [ownerId] + ) + + return result.rows.map((row: any) => ({ + id: row.id.toString(), + prefix: row.prefix, + scopes: row.scopes as KeyScope[], + tier: row.tier, + ownerId: row.owner_id, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + active: row.active, + })) + } + + /** + * Delete all API keys (for testing) + */ + async deleteAll(): Promise { + await this.db.query('DELETE FROM api_keys') + } +} diff --git a/src/middleware/apiKey.ts b/src/middleware/apiKey.ts index 1068282..6692f5c 100644 --- a/src/middleware/apiKey.ts +++ b/src/middleware/apiKey.ts @@ -1,7 +1,7 @@ /** * API key middleware. * - * Provides two middleware functions: + * Provides three middleware functions: * * 1. `apiKeyMiddleware` – Optional. Reads `X-API-Key` and resolves a rate * tier ('standard' | 'premium') stored on `res.locals.rateTier`. Public @@ -10,10 +10,13 @@ * 2. `requireApiKey` – Enforcing. Validates `Authorization: Bearer ` or * `X-API-Key: `, attaches the validated key record to `req.apiKeyRecord`, * and returns 401/403 if the key is missing, revoked, or lacks scope. + * + * 3. `requireScope` – Enforcing. Checks if the validated API key has the + * required scope. Must be used after `requireApiKey`. */ import type { Request, Response, NextFunction } from 'express' -import { validateApiKey, type KeyScope, type StoredApiKey } from '../services/apiKeys.js' +import { validateApiKey, type KeyScope, type StoredApiKey, ApiKeyScope } from '../services/apiKeys.js' // ── Types ──────────────────────────────────────────────────────────────────── @@ -68,37 +71,56 @@ function extractRawKey(req: Request): string | null { * - `Authorization: Bearer ` * - `X-API-Key: ` * - * @param requiredScope Optional scope requirement. Pass `'full'` to restrict - * access to keys with full-access scope. - * * @example * // Require any valid key * router.get('/data', requireApiKey(), handler) * - * // Require full-access key - * router.post('/write', requireApiKey('full'), handler) + * // Require key with specific scope (use requireScope middleware instead) + * router.post('/write', requireApiKey(), requireScope(ApiKeyScope.BOND_WRITE), handler) */ import { UnauthorizedError, ForbiddenError } from '../lib/errors.js' -export function requireApiKey(requiredScope?: KeyScope) { - return (req: Request, res: Response, next: NextFunction): void => { +export function requireApiKey() { + return async (req: Request, res: Response, next: NextFunction): Promise => { const rawKey = extractRawKey(req) if (!rawKey) { throw new UnauthorizedError('API key required') } - const apiKey = validateApiKey(rawKey) + const apiKey = await validateApiKey(rawKey) if (!apiKey) { throw new UnauthorizedError('Invalid or revoked API key') } - if (requiredScope === 'full' && apiKey.scope !== 'full') { - throw new ForbiddenError('Insufficient scope: full access required') + req.apiKeyRecord = apiKey + next() + } +} + +/** + * Express middleware that checks if the validated API key has the required scope. + * Must be used after `requireApiKey` middleware. + * + * @param requiredScope The scope required to access this endpoint + * + * @example + * router.get('/bonds', requireApiKey(), requireScope(ApiKeyScope.BOND_READ), handler) + * router.post('/attestations', requireApiKey(), requireScope(ApiKeyScope.ATTESTATION_WRITE), handler) + */ +export function requireScope(requiredScope: KeyScope) { + return (req: Request, res: Response, next: NextFunction): void => { + const apiKey = req.apiKeyRecord + + if (!apiKey) { + throw new UnauthorizedError('API key required') + } + + if (!apiKey.scopes.includes(requiredScope)) { + throw new ForbiddenError('Insufficient scope') } - req.apiKeyRecord = apiKey next() } } diff --git a/src/migrations/006_add_api_keys_table.ts b/src/migrations/006_add_api_keys_table.ts new file mode 100644 index 0000000..188538d --- /dev/null +++ b/src/migrations/006_add_api_keys_table.ts @@ -0,0 +1,90 @@ +import { MigrationBuilder } from 'node-pg-migrate' + +/** + * Migration: Add API Keys Table with Scopes + * + * Description: Creates the api_keys table to store API keys with their associated scopes. + * This enables fine-grained access control through the requireScope middleware. + * + * Table created: + * - api_keys: Stores API key metadata including hashed keys, scopes, and ownership + */ + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createTable('api_keys', { + id: { + type: 'serial', + primaryKey: true, + }, + hashed_key: { + type: 'varchar(64)', + notNull: true, + unique: true, + }, + prefix: { + type: 'varchar(8)', + notNull: true, + }, + scopes: { + type: 'text[]', + notNull: true, + default: pgm.func("ARRAY[]::text[]"), + }, + tier: { + type: 'varchar(20)', + notNull: true, + default: 'free', + }, + owner_id: { + type: 'varchar(255)', + notNull: true, + }, + created_at: { + type: 'timestamp', + notNull: true, + default: pgm.func('current_timestamp'), + }, + last_used_at: { + type: 'timestamp', + }, + active: { + type: 'boolean', + notNull: true, + default: true, + }, + }) + + // Add indexes for common query patterns + pgm.createIndex('api_keys', 'hashed_key') + pgm.createIndex('api_keys', 'prefix') + pgm.createIndex('api_keys', 'owner_id') + pgm.createIndex('api_keys', 'active') + + // Composite index for active keys by owner + pgm.createIndex('api_keys', ['owner_id', 'active']) + + // Add check constraint for valid tier values + pgm.addConstraint('api_keys', 'api_keys_tier_check', { + check: "tier IN ('free', 'pro', 'enterprise')", + }) + + // Add trigger for updated_at-like behavior on last_used_at + pgm.sql(` + CREATE OR REPLACE FUNCTION update_last_used_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.last_used_at = current_timestamp; + RETURN NEW; + END; + $$ language 'plpgsql'; + `) + + // Note: This trigger would be used when validating keys, not on every UPDATE + // The validation logic will update last_used_at directly +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.sql('DROP TRIGGER IF EXISTS update_api_keys_last_used_at ON api_keys;') + pgm.sql('DROP FUNCTION IF EXISTS update_last_used_at();') + pgm.dropTable('api_keys') +} diff --git a/src/routes/apiKeys.test.ts b/src/routes/apiKeys.test.ts new file mode 100644 index 0000000..dd01634 --- /dev/null +++ b/src/routes/apiKeys.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import request from 'supertest' +import express from 'express' +import apiKeysRouter from './apiKeys.js' +import { generateApiKey, _setUseInMemory, _resetStore, ApiKeyScope } from '../services/apiKeys.js' + +describe('API Keys Routes', () => { + let app: express.Express + let testApiKey: string + + beforeEach(async () => { + _resetStore() + _setUseInMemory(true) + + // Create a test API key with bond:write scope for creating other keys + const result = await generateApiKey('test-owner', [ApiKeyScope.BOND_WRITE], 'pro') + testApiKey = result.key + + // Setup Express app + app = express() + app.use(express.json()) + app.use('/api/api-keys', apiKeysRouter) + }) + + afterEach(() => { + _resetStore() + }) + + describe('POST /api/api-keys', () => { + it('should create a new API key with valid scopes', async () => { + const response = await request(app) + .post('/api/api-keys') + .set('Authorization', `Bearer ${testApiKey}`) + .send({ + ownerId: 'new-owner', + scopes: [ApiKeyScope.BOND_READ, ApiKeyScope.TRUST_READ], + tier: 'free', + }) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty('id') + expect(response.body).toHaveProperty('key') + expect(response.body).toHaveProperty('prefix') + expect(response.body.scopes).toEqual([ApiKeyScope.BOND_READ, ApiKeyScope.TRUST_READ]) + expect(response.body.tier).toBe('free') + }) + + it('should create a key with empty scopes (least privilege)', async () => { + const response = await request(app) + .post('/api/api-keys') + .set('Authorization', `Bearer ${testApiKey}`) + .send({ + ownerId: 'new-owner', + }) + + expect(response.status).toBe(201) + expect(response.body.scopes).toEqual([]) + }) + + it('should reject invalid scopes', async () => { + const response = await request(app) + .post('/api/api-keys') + .set('Authorization', `Bearer ${testApiKey}`) + .send({ + ownerId: 'new-owner', + scopes: ['invalid:scope'], + }) + + expect(response.status).toBe(400) + expect(response.body).toHaveProperty('error') + }) + + it('should require ownerId', async () => { + const response = await request(app) + .post('/api/api-keys') + .set('Authorization', `Bearer ${testApiKey}`) + .send({ + scopes: [ApiKeyScope.BOND_READ], + }) + + expect(response.status).toBe(400) + expect(response.body.error).toBe('ownerId is required') + }) + + it('should reject requests without API key', async () => { + const response = await request(app) + .post('/api/api-keys') + .send({ + ownerId: 'new-owner', + scopes: [ApiKeyScope.BOND_READ], + }) + + expect(response.status).toBe(401) + }) + + it('should reject requests with invalid API key', async () => { + const response = await request(app) + .post('/api/api-keys') + .set('Authorization', 'Bearer invalid-key') + .send({ + ownerId: 'new-owner', + scopes: [ApiKeyScope.BOND_READ], + }) + + expect(response.status).toBe(401) + }) + }) + + describe('GET /api/api-keys/:ownerId', () => { + beforeEach(async () => { + // Create some test keys + await generateApiKey('owner1', [ApiKeyScope.BOND_READ]) + await generateApiKey('owner1', [ApiKeyScope.TRUST_READ]) + await generateApiKey('owner2', [ApiKeyScope.BOND_WRITE]) + }) + + it('should list all keys for an owner', async () => { + const response = await request(app) + .get('/api/api-keys/owner1') + .set('Authorization', `Bearer ${testApiKey}`) + + expect(response.status).toBe(200) + expect(Array.isArray(response.body)).toBe(true) + expect(response.body).toHaveLength(2) + response.body.forEach((key: any) => { + expect(key.ownerId).toBe('owner1') + expect(key).not.toHaveProperty('hashedKey') + }) + }) + + it('should return empty array for owner with no keys', async () => { + const response = await request(app) + .get('/api/api-keys/nonexistent') + .set('Authorization', `Bearer ${testApiKey}`) + + expect(response.status).toBe(200) + expect(response.body).toEqual([]) + }) + + it('should require authentication', async () => { + const response = await request(app).get('/api/api-keys/owner1') + + expect(response.status).toBe(401) + }) + }) + + describe('DELETE /api/api-keys/:id', () => { + it('should revoke an existing key', async () => { + const key = await generateApiKey('owner1', [ApiKeyScope.BOND_READ]) + + const response = await request(app) + .delete(`/api/api-keys/${key.id}`) + .set('Authorization', `Bearer ${testApiKey}`) + + expect(response.status).toBe(204) + }) + + it('should return 404 for non-existent key', async () => { + const response = await request(app) + .delete('/api/api-keys/nonexistent-id') + .set('Authorization', `Bearer ${testApiKey}`) + + expect(response.status).toBe(404) + }) + + it('should require authentication', async () => { + const response = await request(app).delete('/api/api-keys/some-id') + + expect(response.status).toBe(401) + }) + }) + + describe('POST /api/api-keys/:id/rotate', () => { + it('should rotate an existing key', async () => { + const key = await generateApiKey('owner1', [ApiKeyScope.BOND_READ, ApiKeyScope.TRUST_READ]) + + const response = await request(app) + .post(`/api/api-keys/${key.id}/rotate`) + .set('Authorization', `Bearer ${testApiKey}`) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty('key') + expect(response.body).toHaveProperty('id') + expect(response.body.scopes).toEqual([ApiKeyScope.BOND_READ, ApiKeyScope.TRUST_READ]) + expect(response.body.key).not.toBe(key.key) + }) + + it('should return 404 for non-existent key', async () => { + const response = await request(app) + .post('/api/api-keys/nonexistent-id/rotate') + .set('Authorization', `Bearer ${testApiKey}`) + + expect(response.status).toBe(404) + }) + + it('should require authentication', async () => { + const response = await request(app).post('/api/api-keys/some-id/rotate') + + expect(response.status).toBe(401) + }) + }) +}) diff --git a/src/services/apiKeys.test.ts b/src/services/apiKeys.test.ts index 5934738..fc82802 100644 --- a/src/services/apiKeys.test.ts +++ b/src/services/apiKeys.test.ts @@ -6,170 +6,174 @@ import { rotateApiKey, listApiKeys, _resetStore, + _setUseInMemory, + ApiKeyScope, } from './apiKeys.js' beforeEach(() => { _resetStore() + _setUseInMemory(true) }) describe('generateApiKey', () => { - it('returns a key matching the cr_<64 hex> format', () => { - const result = generateApiKey('owner1') + it('returns a key matching the cr_<64 hex> format', async () => { + const result = await generateApiKey('owner1') expect(result.key).toMatch(/^cr_[0-9a-f]{64}$/) }) - it('defaults scope to read and tier to free', () => { - const result = generateApiKey('owner1') - expect(result.scope).toBe('read') + it('defaults scopes to empty array (least privilege) and tier to free', async () => { + const result = await generateApiKey('owner1') + expect(result.scopes).toEqual([]) expect(result.tier).toBe('free') }) - it('respects custom scope and tier', () => { - const result = generateApiKey('owner1', 'full', 'pro') - expect(result.scope).toBe('full') + it('respects custom scopes and tier', async () => { + const result = await generateApiKey('owner1', [ApiKeyScope.BOND_READ, ApiKeyScope.TRUST_READ], 'pro') + expect(result.scopes).toEqual([ApiKeyScope.BOND_READ, ApiKeyScope.TRUST_READ]) expect(result.tier).toBe('pro') }) - it('generates unique keys and IDs on each call', () => { - const a = generateApiKey('owner1') - const b = generateApiKey('owner1') + it('generates unique keys and IDs on each call', async () => { + const a = await generateApiKey('owner1') + const b = await generateApiKey('owner1') expect(a.key).not.toBe(b.key) expect(a.id).not.toBe(b.id) }) - it('sets createdAt to approximately now', () => { + it('sets createdAt to approximately now', async () => { const before = Date.now() - const result = generateApiKey('owner1') + const result = await generateApiKey('owner1') expect(result.createdAt.getTime()).toBeGreaterThanOrEqual(before) expect(result.createdAt.getTime()).toBeLessThanOrEqual(Date.now()) }) }) describe('validateApiKey', () => { - it('validates a freshly generated key', () => { - const { key } = generateApiKey('owner1') - const result = validateApiKey(key) + it('validates a freshly generated key', async () => { + const { key } = await generateApiKey('owner1') + const result = await validateApiKey(key) expect(result).not.toBeNull() expect(result?.active).toBe(true) }) - it('returns null for keys with invalid format', () => { - expect(validateApiKey('')).toBeNull() - expect(validateApiKey('invalid')).toBeNull() - expect(validateApiKey('sk_badprefix')).toBeNull() - expect(validateApiKey('cr_tooshort')).toBeNull() + it('returns null for keys with invalid format', async () => { + expect(await validateApiKey('')).toBeNull() + expect(await validateApiKey('invalid')).toBeNull() + expect(await validateApiKey('sk_badprefix')).toBeNull() + expect(await validateApiKey('cr_tooshort')).toBeNull() // Correct length but wrong prefix - expect(validateApiKey('xx_' + 'a'.repeat(64))).toBeNull() + expect(await validateApiKey('xx_' + 'a'.repeat(64))).toBeNull() // Correct prefix but non-hex content - expect(validateApiKey('cr_' + 'z'.repeat(64))).toBeNull() + expect(await validateApiKey('cr_' + 'z'.repeat(64))).toBeNull() }) - it('returns null for an unknown key with valid format', () => { - expect(validateApiKey('cr_' + 'a'.repeat(64))).toBeNull() + it('returns null for an unknown key with valid format', async () => { + expect(await validateApiKey('cr_' + 'a'.repeat(64))).toBeNull() }) - it('updates lastUsedAt on successful validation', () => { - const { key } = generateApiKey('owner1') + it('updates lastUsedAt on successful validation', async () => { + const { key } = await generateApiKey('owner1') const before = Date.now() - const result = validateApiKey(key) + const result = await validateApiKey(key) expect(result?.lastUsedAt).not.toBeNull() expect(result?.lastUsedAt!.getTime()).toBeGreaterThanOrEqual(before) }) - it('returns null for a revoked key', () => { - const { id, key } = generateApiKey('owner1') - revokeApiKey(id) - expect(validateApiKey(key)).toBeNull() + it('returns null for a revoked key', async () => { + const { id, key } = await generateApiKey('owner1') + await revokeApiKey(id) + expect(await validateApiKey(key)).toBeNull() }) }) describe('revokeApiKey', () => { - it('deactivates an active key', () => { - const { id, key } = generateApiKey('owner1') - expect(revokeApiKey(id)).toBe(true) - expect(validateApiKey(key)).toBeNull() + it('deactivates an active key', async () => { + const { id, key } = await generateApiKey('owner1') + expect(await revokeApiKey(id)).toBe(true) + expect(await validateApiKey(key)).toBeNull() }) - it('returns false for an unknown ID', () => { - expect(revokeApiKey('nonexistent')).toBe(false) + it('returns false for an unknown ID', async () => { + expect(await revokeApiKey('nonexistent')).toBe(false) }) - it('can revoke the same key twice without error', () => { - const { id } = generateApiKey('owner1') - expect(revokeApiKey(id)).toBe(true) + it('can revoke the same key twice without error', async () => { + const { id } = await generateApiKey('owner1') + expect(await revokeApiKey(id)).toBe(true) // Second call still returns true — key exists, just already inactive - expect(revokeApiKey(id)).toBe(true) + expect(await revokeApiKey(id)).toBe(true) }) }) describe('rotateApiKey', () => { - it('returns a new key with the same scope and tier', () => { - const { id } = generateApiKey('owner1', 'full', 'pro') - const result = rotateApiKey(id) + it('returns a new key with the same scopes and tier', async () => { + const { id } = await generateApiKey('owner1', [ApiKeyScope.BOND_WRITE], 'pro') + const result = await rotateApiKey(id) expect(result).not.toBeNull() - expect(result?.scope).toBe('full') + expect(result?.scopes).toEqual([ApiKeyScope.BOND_WRITE]) expect(result?.tier).toBe('pro') }) - it('invalidates the old key after rotation', () => { - const { id, key: oldKey } = generateApiKey('owner1') - rotateApiKey(id) - expect(validateApiKey(oldKey)).toBeNull() + it('invalidates the old key after rotation', async () => { + const { id, key: oldKey } = await generateApiKey('owner1') + await rotateApiKey(id) + expect(await validateApiKey(oldKey)).toBeNull() }) - it('new key is immediately valid', () => { - const { id } = generateApiKey('owner1') - const { key: newKey } = rotateApiKey(id)! - expect(validateApiKey(newKey)).not.toBeNull() + it('new key is immediately valid', async () => { + const { id } = await generateApiKey('owner1') + const { key: newKey } = (await rotateApiKey(id))! + expect(await validateApiKey(newKey)).not.toBeNull() }) - it('new key differs from the old key', () => { - const { id, key: oldKey } = generateApiKey('owner1') - const result = rotateApiKey(id) + it('new key differs from the old key', async () => { + const { id, key: oldKey } = await generateApiKey('owner1') + const result = await rotateApiKey(id) expect(result?.key).not.toBe(oldKey) }) - it('returns null for an unknown ID', () => { - expect(rotateApiKey('nonexistent')).toBeNull() + it('returns null for an unknown ID', async () => { + expect(await rotateApiKey('nonexistent')).toBeNull() }) - it('returns null when the key is already revoked', () => { - const { id } = generateApiKey('owner1') - revokeApiKey(id) - expect(rotateApiKey(id)).toBeNull() + it('returns null when the key is already revoked', async () => { + const { id } = await generateApiKey('owner1') + await revokeApiKey(id) + expect(await rotateApiKey(id)).toBeNull() }) }) describe('listApiKeys', () => { - it('returns only keys belonging to the requested owner', () => { - generateApiKey('owner1') - generateApiKey('owner1', 'full') - generateApiKey('owner2') + it('returns only keys belonging to the requested owner', async () => { + await generateApiKey('owner1') + await generateApiKey('owner1', [ApiKeyScope.BOND_WRITE]) + await generateApiKey('owner2') - const keys = listApiKeys('owner1') + const keys = await listApiKeys('owner1') expect(keys).toHaveLength(2) - keys.forEach((k) => expect(k.ownerId).toBe('owner1')) + keys.forEach((k: any) => expect(k.ownerId).toBe('owner1')) }) - it('never exposes the hashedKey field', () => { - generateApiKey('owner1') - listApiKeys('owner1').forEach((k) => { + it('never exposes the hashedKey field', async () => { + await generateApiKey('owner1') + const keys = await listApiKeys('owner1') + keys.forEach((k: any) => { expect(k).not.toHaveProperty('hashedKey') }) }) - it('includes both active and revoked keys', () => { - const { id } = generateApiKey('owner1') - generateApiKey('owner1') - revokeApiKey(id) + it('includes both active and revoked keys', async () => { + const { id } = await generateApiKey('owner1') + await generateApiKey('owner1') + await revokeApiKey(id) - const keys = listApiKeys('owner1') + const keys = await listApiKeys('owner1') expect(keys).toHaveLength(2) - expect(keys.some((k) => !k.active)).toBe(true) - expect(keys.some((k) => k.active)).toBe(true) + expect(keys.some((k: any) => !k.active)).toBe(true) + expect(keys.some((k: any) => k.active)).toBe(true) }) - it('returns an empty array for an unknown owner', () => { - expect(listApiKeys('nobody')).toHaveLength(0) + it('returns an empty array for an unknown owner', async () => { + expect(await listApiKeys('nobody')).toHaveLength(0) }) }) diff --git a/src/services/apiKeys.ts b/src/services/apiKeys.ts index e36f7f6..997d1ed 100644 --- a/src/services/apiKeys.ts +++ b/src/services/apiKeys.ts @@ -1,4 +1,6 @@ import { randomBytes, createHash } from 'crypto' +import { ApiKeysRepository } from '../db/repositories/apiKeysRepository.js' +import { pool } from '../db/pool.js' export type KeyScope = 'read' | 'full' | string // extended to accept granular scope strings export type SubscriptionTier = 'free' | 'pro' | 'enterprise' @@ -40,8 +42,12 @@ export interface CreateApiKeyResult { createdAt: Date } -// In-memory store — replace with a DB adapter in production -const store = new Map() +// Repository for database operations +const repository = new ApiKeysRepository(pool) + +// In-memory fallback for testing when DB is not available +const inMemoryStore = new Map() +let useInMemory = process.env.NODE_ENV === 'test' && !process.env.TEST_WITH_DB function hashKey(rawKey: string): string { return createHash('sha256').update(rawKey).digest('hex') @@ -61,9 +67,9 @@ function extractPrefix(rawKey: string): string { * @param scopes Optional explicit list of granted scopes. When provided, overrides `scope`. * @returns Key metadata including the raw key (shown once only) */ -export function generateApiKey( +export async function generateApiKey( ownerId: string, - scope: KeyScope = 'read', + scopes: KeyScope[] = [], tier: SubscriptionTier = 'free', scopes?: string[], ): CreateApiKeyResult { @@ -106,20 +112,29 @@ export function generateApiKey( * @param rawKey The key supplied by the caller * @returns The stored key record (with lastUsedAt updated) or null if invalid/revoked */ -export function validateApiKey(rawKey: string): StoredApiKey | null { +export async function validateApiKey(rawKey: string): Promise { if (!/^cr_[0-9a-f]{64}$/.test(rawKey)) return null const prefix = extractPrefix(rawKey) const hashed = hashKey(rawKey) - for (const key of store.values()) { - if (key.prefix === prefix && key.hashedKey === hashed) { - if (!key.active) return null - key.lastUsedAt = new Date() - return key + if (useInMemory) { + for (const key of inMemoryStore.values()) { + if (key.prefix === prefix && key.hashedKey === hashed) { + if (!key.active) return null + key.lastUsedAt = new Date() + return key + } + } + return null + } else { + const apiKey = await repository.findByHashAndPrefix(hashed, prefix) + if (apiKey) { + await repository.updateLastUsedAt(apiKey.id) + apiKey.lastUsedAt = new Date() } + return apiKey } - return null } /** @@ -127,16 +142,20 @@ export function validateApiKey(rawKey: string): StoredApiKey | null { * * @returns true if the key was found and deactivated, false if not found */ -export function revokeApiKey(id: string): boolean { - const key = store.get(id) - if (!key) return false - key.active = false - return true +export async function revokeApiKey(id: string): Promise { + if (useInMemory) { + const key = inMemoryStore.get(id) + if (!key) return false + key.active = false + return true + } else { + return await repository.revokeApiKey(id) + } } /** * Rotate an API key: revokes the existing key and issues a new one with the same - * scope, tier, and owner. Returns null if the key doesn't exist or is already revoked. + * scopes, tier, and owner. Returns null if the key doesn't exist or is already revoked. */ export function rotateApiKey(id: string): CreateApiKeyResult | null { const existing = store.get(id) @@ -161,13 +180,26 @@ export function findApiKeyById(id: string): Omit | nu /** * List all keys for an owner. The `hashedKey` field is omitted. */ -export function listApiKeys(ownerId: string): Omit[] { - return [...store.values()] - .filter((k) => k.ownerId === ownerId) - .map(({ hashedKey: _h, ...rest }) => rest) +export async function listApiKeys(ownerId: string): Promise[]> { + if (useInMemory) { + return [...inMemoryStore.values()] + .filter((k) => k.ownerId === ownerId) + .map(({ hashedKey: _h, ...rest }) => rest) + } else { + return await repository.listByOwner(ownerId) + } } /** Reset the in-memory store. Intended for use in tests only. */ export function _resetStore(): void { - store.clear() + inMemoryStore.clear() + if (!useInMemory) { + // In DB mode, we'd need to truncate the table + // For now, this is only used in tests which use in-memory mode + } +} + +/** Force use of in-memory store (for testing) */ +export function _setUseInMemory(value: boolean): void { + useInMemory = value }