Add authentication and idempotency middleware, implement graceful shutdown, and update dependencies

- Introduced `authMiddleware` for request authentication and `optionalAuthMiddleware` for optional authentication.
- Added `idempotencyMiddleware` to ensure idempotent requests are processed correctly, including Redis caching.
- Implemented graceful shutdown for the REST API server to close Redis connections on termination signals.
- Updated `package.json` to include `jsonwebtoken` and its type definitions.
This commit is contained in:
defiQUG
2025-12-12 11:17:59 -08:00
parent 6915dbd319
commit c32fcf48e8
11 changed files with 1354 additions and 17 deletions

View File

@@ -18,6 +18,7 @@ import { triggersRouter } from './routes/triggers';
import { isoRouter } from './routes/iso';
import { packetsRouter } from './routes/packets';
import { bridgeRouter } from './routes/bridge';
import { closeRedisClient } from './services/redis';
const app = express();
const PORT = process.env.PORT || 3000;
@@ -61,9 +62,26 @@ app.get('/health', (req, res) => {
// Error handler (must be last)
app.use(errorHandler);
app.listen(PORT, () => {
const server = app.listen(PORT, () => {
console.log(`REST API server listening on port ${PORT}`);
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully...');
server.close(async () => {
await closeRedisClient();
process.exit(0);
});
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully...');
server.close(async () => {
await closeRedisClient();
process.exit(0);
});
});
export default app;

View File

@@ -0,0 +1,153 @@
# Authentication & Authorization Middleware
## Overview
This directory contains middleware for authentication, authorization, and idempotency handling.
## Authentication Middleware (`auth.ts`)
Supports three authentication methods:
1. **OAuth2 Bearer Token** - JWT tokens with client credentials flow
2. **mTLS** - Mutual TLS for adapter endpoints
3. **API Key** - X-API-Key header for internal services
### Usage
```typescript
import { authMiddleware } from './middleware/auth';
app.use(authMiddleware);
```
### Configuration
Set environment variables:
- `JWT_SECRET` - Secret for JWT token validation
- `OAUTH2_CLIENT_SECRET` - OAuth2 client secret
- `API_KEY` - API key for internal services
### Request Context
After authentication, `req.auth` contains:
```typescript
{
clientId: string;
scopes: string[];
roles: string[];
authMethod: 'oauth2' | 'mtls' | 'apikey';
certificate?: string; // For mTLS
}
```
## RBAC Middleware (`rbac.ts`)
Role-Based Access Control with role hierarchy support.
### Available Roles
- `GOVERNANCE_ADMIN` - Full access (inherits all roles)
- `TOKEN_DEPLOYER` - Can deploy tokens
- `POLICY_OPERATOR` - Can manage policies
- `ISSUER` - Can mint/burn tokens
- `ENFORCEMENT` - Can clawback/force-transfer
- `COMPLIANCE` - Can manage compliance
- `DEBT_AUTHORITY` - Can manage liens
- `BRIDGE_OPERATOR` - Can operate bridge
### Usage
```typescript
import { requireRole, requireAnyRole, requireAllRoles, requireScope } from './middleware/rbac';
// Require single role
router.post('/', requireRole('ISSUER'), handler);
// Require any of multiple roles
router.post('/', requireAnyRole('ISSUER', 'ENFORCEMENT'), handler);
// Require all roles
router.post('/', requireAllRoles('ISSUER', 'COMPLIANCE'), handler);
// Require scope
router.get('/', requireScope('read'), handler);
```
## Idempotency Middleware (`idempotency.ts`)
Ensures requests with the same idempotency key are only processed once.
### Usage
```typescript
import { idempotencyMiddleware } from './middleware/idempotency';
app.use(idempotencyMiddleware);
```
### Requirements
- Redis must be configured (`REDIS_URL` environment variable)
- Client must send `Idempotency-Key` header
- Only applies to write operations (POST, PUT, PATCH, DELETE)
### Idempotency Key Format
- 1-255 characters
- Alphanumeric, hyphens, and underscores only
- Example: `req-123e4567-e89b-12d3-a456-426614174000`
### Response Headers
- `Idempotency-Key` - Echoed from request
- `Idempotency-Replayed` - Set to `true` if response was replayed
### Configuration
Set environment variable:
- `REDIS_URL` - Redis connection URL (default: `redis://localhost:6379`)
## Error Responses
### Authentication Errors (401)
```json
{
"code": "UNAUTHORIZED",
"message": "Authentication required",
"reasonCode": "AUTH_REQUIRED"
}
```
### Authorization Errors (403)
```json
{
"code": "FORBIDDEN",
"message": "Required role: ISSUER",
"reasonCode": "INSUFFICIENT_PERMISSIONS",
"requiredRole": "ISSUER",
"userRoles": ["COMPLIANCE"]
}
```
### Idempotency Errors (400)
```json
{
"code": "INVALID_IDEMPOTENCY_KEY",
"message": "Idempotency key must be 1-255 characters...",
"reasonCode": "INVALID_IDEMPOTENCY_KEY"
}
```
## Production Considerations
1. **Client Registry**: Replace in-memory registry with database
2. **JWT Validation**: Use JWKS endpoint for token validation
3. **mTLS Certificate Management**: Implement certificate rotation
4. **API Key Management**: Use hashed keys in database
5. **Redis High Availability**: Use Redis cluster for production
6. **Rate Limiting**: Add rate limiting per client
7. **Audit Logging**: Log all authentication attempts

View File

@@ -4,13 +4,61 @@
*/
import { Request, Response, NextFunction } from 'express';
import { authenticateRequest, AuthContext } from '../services/auth-service';
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
// TODO: Implement OAuth2 token validation
// TODO: Implement mTLS validation for adapter endpoints
// TODO: Implement API key validation for internal services
// For now, pass through (will be implemented in Phase 6)
next();
// Extend Express Request to include auth context
declare global {
namespace Express {
interface Request {
auth?: AuthContext;
}
}
}
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
try {
// Skip auth for health check and public endpoints
if (req.path === '/health' || req.path.startsWith('/api-docs')) {
return next();
}
// Authenticate request
const authContext = await authenticateRequest(req);
if (!authContext) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Authentication required',
reasonCode: 'AUTH_REQUIRED',
});
}
// Attach auth context to request
req.auth = authContext;
next();
} catch (error: any) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: error.message || 'Authentication failed',
reasonCode: 'AUTH_FAILED',
});
}
}
/**
* Optional authentication middleware
* Attaches auth context if available but doesn't require it
*/
export async function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction) {
try {
const authContext = await authenticateRequest(req);
if (authContext) {
req.auth = authContext;
}
next();
} catch {
// Ignore auth errors for optional auth
next();
}
}

View File

@@ -1,21 +1,168 @@
/**
* Idempotency middleware
* Ensures requests with same idempotency key are only processed once
*
* Implements idempotency per RFC 7231 and common API practices:
* - Stores responses in Redis with TTL
* - Returns cached response for duplicate requests
* - Validates idempotency key format
*/
import { Request, Response, NextFunction } from 'express';
// import { redisClient } from '../services/redis';
import { getRedisClient } from '../services/redis';
const IDEMPOTENCY_TTL = 24 * 60 * 60; // 24 hours in seconds
interface IdempotencyRecord {
statusCode: number;
headers: Record<string, string>;
body: string;
createdAt: number;
}
/**
* Generate cache key from idempotency key and request
*/
function generateCacheKey(idempotencyKey: string, req: Request): string {
// Include method, path, and body hash for safety
const method = req.method;
const path = req.path;
const bodyHash = req.body ? JSON.stringify(req.body) : '';
return `idempotency:${idempotencyKey}:${method}:${path}:${bodyHash}`;
}
/**
* Idempotency middleware
*/
export async function idempotencyMiddleware(req: Request, res: Response, next: NextFunction) {
const idempotencyKey = req.headers['idempotency-key'] as string;
if (!idempotencyKey) {
// Only apply to write operations
const writeMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (!idempotencyKey || !writeMethods.includes(req.method)) {
return next();
}
// TODO: Check Redis for existing response
// TODO: Store response in Redis for replay
// For now, pass through (will be implemented in Phase 6)
next();
// Validate idempotency key format
if (!validateIdempotencyKey(idempotencyKey)) {
return res.status(400).json({
code: 'INVALID_IDEMPOTENCY_KEY',
message: 'Idempotency key must be 1-255 characters and contain only alphanumeric, hyphens, and underscores',
reasonCode: 'INVALID_IDEMPOTENCY_KEY',
});
}
try {
const redis = await getRedisClient();
const cacheKey = generateCacheKey(idempotencyKey, req);
// Check for existing response
const existing = await redis.get(cacheKey);
if (existing) {
const record: IdempotencyRecord = JSON.parse(existing);
// Return cached response
res.status(record.statusCode);
// Set cached headers
Object.entries(record.headers).forEach(([key, value]) => {
res.setHeader(key, value);
});
// Add idempotency header
res.setHeader('Idempotency-Key', idempotencyKey);
res.setHeader('Idempotency-Replayed', 'true');
return res.json(JSON.parse(record.body));
}
// Store original methods
const originalJson = res.json.bind(res);
const originalStatus = res.status.bind(res);
const originalEnd = res.end.bind(res);
let statusCode = 200;
const responseHeaders: Record<string, string> = {};
let responseBody: string = '';
// Override res.json to capture response
res.json = function(body: any) {
statusCode = res.statusCode || 200;
responseBody = JSON.stringify(body);
// Capture headers
const headers = res.getHeaders();
Object.entries(headers).forEach(([key, value]) => {
if (typeof value === 'string') {
responseHeaders[key] = value;
}
});
// Store in Redis
const record: IdempotencyRecord = {
statusCode,
headers: responseHeaders,
body: responseBody,
createdAt: Date.now(),
};
redis.setEx(cacheKey, IDEMPOTENCY_TTL, JSON.stringify(record)).catch(err => {
console.error('Failed to store idempotency record:', err);
});
// Call original json method
return originalJson(body);
};
// Override res.status to capture status code
res.status = function(code: number) {
statusCode = code;
return originalStatus(code);
};
// Override res.end to ensure we capture the response
res.end = function(chunk?: any, encoding?: any) {
if (chunk && !responseBody) {
responseBody = chunk.toString();
const record: IdempotencyRecord = {
statusCode,
headers: responseHeaders,
body: responseBody,
createdAt: Date.now(),
};
redis.setEx(cacheKey, IDEMPOTENCY_TTL, JSON.stringify(record)).catch(err => {
console.error('Failed to store idempotency record:', err);
});
}
return originalEnd(chunk, encoding);
};
next();
} catch (error: any) {
// If Redis fails, log error but continue (fail open)
console.error('Idempotency middleware error:', error);
next();
}
}
/**
* Validate idempotency key format
*/
export function validateIdempotencyKey(key: string): boolean {
// Idempotency keys should be:
// - Non-empty
// - Max 255 characters (per HTTP spec)
// - Alphanumeric with hyphens/underscores
if (!key || key.length === 0 || key.length > 255) {
return false;
}
// Allow alphanumeric, hyphens, underscores
const validPattern = /^[a-zA-Z0-9_-]+$/;
return validPattern.test(key);
}

View File

@@ -4,11 +4,164 @@
import { Request, Response, NextFunction } from 'express';
// Role hierarchy (higher roles inherit lower role permissions)
const roleHierarchy: Record<string, string[]> = {
'GOVERNANCE_ADMIN': [
'TOKEN_DEPLOYER',
'POLICY_OPERATOR',
'ISSUER',
'ENFORCEMENT',
'COMPLIANCE',
'DEBT_AUTHORITY',
'BRIDGE_OPERATOR',
],
'TOKEN_DEPLOYER': [],
'POLICY_OPERATOR': [],
'ISSUER': [],
'ENFORCEMENT': [],
'COMPLIANCE': [],
'DEBT_AUTHORITY': [],
'BRIDGE_OPERATOR': [],
};
/**
* Check if user has required role
*/
function hasRole(userRoles: string[], requiredRole: string): boolean {
// Direct role match
if (userRoles.includes(requiredRole)) {
return true;
}
// Check role hierarchy
for (const userRole of userRoles) {
const inheritedRoles = roleHierarchy[userRole] || [];
if (inheritedRoles.includes(requiredRole)) {
return true;
}
}
return false;
}
/**
* Require a specific role
*/
export function requireRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
// TODO: Check user roles from token/context
// For now, pass through (will be implemented in Phase 6)
// Check if request is authenticated
if (!req.auth) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Authentication required',
reasonCode: 'AUTH_REQUIRED',
});
}
// Check if user has required role
const userRoles = req.auth.roles || [];
if (!hasRole(userRoles, role)) {
return res.status(403).json({
code: 'FORBIDDEN',
message: `Required role: ${role}`,
reasonCode: 'INSUFFICIENT_PERMISSIONS',
requiredRole: role,
userRoles,
});
}
next();
};
}
/**
* Require any of the specified roles
*/
export function requireAnyRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.auth) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Authentication required',
reasonCode: 'AUTH_REQUIRED',
});
}
const userRoles = req.auth.roles || [];
const hasAnyRole = roles.some(role => hasRole(userRoles, role));
if (!hasAnyRole) {
return res.status(403).json({
code: 'FORBIDDEN',
message: `Required one of roles: ${roles.join(', ')}`,
reasonCode: 'INSUFFICIENT_PERMISSIONS',
requiredRoles: roles,
userRoles,
});
}
next();
};
}
/**
* Require all of the specified roles
*/
export function requireAllRoles(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.auth) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Authentication required',
reasonCode: 'AUTH_REQUIRED',
});
}
const userRoles = req.auth.roles || [];
const hasAllRoles = roles.every(role => hasRole(userRoles, role));
if (!hasAllRoles) {
return res.status(403).json({
code: 'FORBIDDEN',
message: `Required all roles: ${roles.join(', ')}`,
reasonCode: 'INSUFFICIENT_PERMISSIONS',
requiredRoles: roles,
userRoles,
});
}
next();
};
}
/**
* Require a specific scope
*/
export function requireScope(scope: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.auth) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Authentication required',
reasonCode: 'AUTH_REQUIRED',
});
}
const userScopes = req.auth.scopes || [];
if (!userScopes.includes(scope)) {
return res.status(403).json({
code: 'FORBIDDEN',
message: `Required scope: ${scope}`,
reasonCode: 'INSUFFICIENT_SCOPES',
requiredScope: scope,
userScopes,
});
}
next();
};
}

View File

@@ -0,0 +1,21 @@
/**
* Authentication service tests
* Example test file for auth service
*/
import { validateOAuth2Token, validateAPIKey, initializeAuthService } from './auth-service';
describe('AuthService', () => {
beforeEach(() => {
initializeAuthService();
});
// Example test - would need proper JWT setup
// it('should validate OAuth2 token', async () => {
// const token = 'valid-jwt-token';
// const context = await validateOAuth2Token(token);
// expect(context.clientId).toBeDefined();
// expect(context.roles).toBeDefined();
// });
});

View File

@@ -0,0 +1,216 @@
/**
* Authentication service
* Handles OAuth2 token validation, mTLS, and API key validation
*/
import jwt from 'jsonwebtoken';
import { createHash } from 'crypto';
import { X509Certificate } from 'crypto';
export interface TokenPayload {
sub: string; // Client ID
client_id: string;
scope: string; // Space-separated scopes
roles?: string[]; // User roles
exp: number;
iat: number;
}
export interface AuthContext {
clientId: string;
scopes: string[];
roles: string[];
authMethod: 'oauth2' | 'mtls' | 'apikey';
certificate?: string; // For mTLS
}
// In-memory client registry (in production, use database)
const clientRegistry = new Map<string, {
clientId: string;
clientSecret: string;
roles: string[];
scopes: string[];
redirectUris?: string[];
}>();
// API key registry (in production, use database with hashed keys)
const apiKeyRegistry = new Map<string, {
keyId: string;
hashedKey: string;
roles: string[];
scopes: string[];
expiresAt?: number;
}>();
// mTLS certificate registry (in production, use database)
const mtlsRegistry = new Map<string, {
certificateFingerprint: string;
clientId: string;
roles: string[];
scopes: string[];
}>();
// Initialize with default clients (in production, load from database)
export function initializeAuthService() {
// Example OAuth2 client
clientRegistry.set('example-client', {
clientId: 'example-client',
clientSecret: process.env.OAUTH2_CLIENT_SECRET || 'change-me',
roles: ['ISSUER', 'COMPLIANCE'],
scopes: ['read', 'write'],
});
// Example API key
const apiKey = process.env.API_KEY || 'test-api-key';
const hashedKey = createHash('sha256').update(apiKey).digest('hex');
apiKeyRegistry.set(hashedKey, {
keyId: 'internal-service-1',
hashedKey,
roles: ['POLICY_OPERATOR', 'ENFORCEMENT'],
scopes: ['read', 'write'],
});
}
/**
* Validate OAuth2 Bearer token
*/
export async function validateOAuth2Token(token: string): Promise<AuthContext> {
try {
// In production, verify against OAuth2 server or use JWKS
const secret = process.env.JWT_SECRET || process.env.OAUTH2_CLIENT_SECRET || 'change-me';
const decoded = jwt.verify(token, secret) as TokenPayload;
// Check if token is expired
if (decoded.exp && decoded.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
// Get client info
const clientId = decoded.client_id || decoded.sub;
const client = clientRegistry.get(clientId);
if (!client) {
throw new Error('Client not found');
}
// Parse scopes
const scopes = decoded.scope ? decoded.scope.split(' ') : [];
// Get roles from token or client registry
const roles = decoded.roles || client.roles || [];
return {
clientId,
scopes,
roles,
authMethod: 'oauth2',
};
} catch (error: any) {
if (error.name === 'JsonWebTokenError') {
throw new Error('Invalid token');
}
if (error.name === 'TokenExpiredError') {
throw new Error('Token expired');
}
throw error;
}
}
/**
* Validate mTLS certificate
*/
export async function validateMTLS(cert: string): Promise<AuthContext> {
try {
const x509 = new X509Certificate(cert);
// Calculate certificate fingerprint
const fingerprint = createHash('sha256')
.update(Buffer.from(x509.raw))
.digest('hex');
// Look up certificate in registry
const certInfo = mtlsRegistry.get(fingerprint);
if (!certInfo) {
throw new Error('Certificate not authorized');
}
// Verify certificate is not expired
const notAfter = new Date(x509.validTo);
if (notAfter < new Date()) {
throw new Error('Certificate expired');
}
return {
clientId: certInfo.clientId,
scopes: certInfo.scopes,
roles: certInfo.roles,
authMethod: 'mtls',
certificate: fingerprint,
};
} catch (error: any) {
throw new Error(`mTLS validation failed: ${error.message}`);
}
}
/**
* Validate API key
*/
export async function validateAPIKey(apiKey: string): Promise<AuthContext> {
const hashedKey = createHash('sha256').update(apiKey).digest('hex');
const keyInfo = apiKeyRegistry.get(hashedKey);
if (!keyInfo) {
throw new Error('Invalid API key');
}
// Check expiration
if (keyInfo.expiresAt && keyInfo.expiresAt < Date.now()) {
throw new Error('API key expired');
}
return {
clientId: keyInfo.keyId,
scopes: keyInfo.scopes,
roles: keyInfo.roles,
authMethod: 'apikey',
};
}
/**
* Extract authentication from request
*/
export async function authenticateRequest(req: any): Promise<AuthContext | null> {
// Try OAuth2 Bearer token first
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
return await validateOAuth2Token(token);
}
// Try mTLS (for adapter endpoints)
if (req.socket && req.socket.getPeerCertificate) {
const cert = req.socket.getPeerCertificate(true);
if (cert && cert.raw) {
try {
return await validateMTLS(cert.raw.toString('base64'));
} catch {
// mTLS validation failed, continue to other methods
}
}
}
// Try API key
const apiKey = req.headers['x-api-key'] as string;
if (apiKey) {
return await validateAPIKey(apiKey);
}
return null;
}
// Initialize on module load
initializeAuthService();

View File

@@ -0,0 +1,31 @@
/**
* Redis client for idempotency and caching
*/
import { createClient, RedisClientType } from 'redis';
let redisClient: RedisClientType | null = null;
export async function getRedisClient(): Promise<RedisClientType> {
if (redisClient && redisClient.isOpen) {
return redisClient;
}
const url = process.env.REDIS_URL || 'redis://localhost:6379';
redisClient = createClient({ url }) as RedisClientType;
redisClient.on('error', (err) => {
console.error('Redis Client Error:', err);
});
await redisClient.connect();
return redisClient;
}
export async function closeRedisClient(): Promise<void> {
if (redisClient && redisClient.isOpen) {
await redisClient.quit();
redisClient = null;
}
}