Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs/api-keys.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 <key_with_bond:write_scope>

{
"ownerId": "user_abc",
Expand Down Expand Up @@ -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 <valid_api_key>
```

Response omits the raw key and the stored hash:
Expand All @@ -144,15 +146,17 @@ 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 <valid_api_key>
```

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 <valid_api_key>
```

Response: **204 No Content**. Subsequent requests using the revoked key receive **401 Unauthorized**.
Expand All @@ -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.

Expand Down
125 changes: 125 additions & 0 deletions src/db/repositories/apiKeysRepository.ts
Original file line number Diff line number Diff line change
@@ -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<StoredApiKey, 'id'>): Promise<StoredApiKey> {
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<StoredApiKey | null> {
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<void> {
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<boolean> {
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<Omit<StoredApiKey, 'hashedKey'>[]> {
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<void> {
await this.db.query('DELETE FROM api_keys')
}
}
48 changes: 35 additions & 13 deletions src/middleware/apiKey.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,10 +10,13 @@
* 2. `requireApiKey` – Enforcing. Validates `Authorization: Bearer <key>` or
* `X-API-Key: <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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -68,37 +71,56 @@ function extractRawKey(req: Request): string | null {
* - `Authorization: Bearer <key>`
* - `X-API-Key: <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<void> => {
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()
}
}
90 changes: 90 additions & 0 deletions src/migrations/006_add_api_keys_table.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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')
}
Loading
Loading