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:
@@ -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;
|
||||
|
||||
|
||||
153
api/services/rest-api/src/middleware/README.md
Normal file
153
api/services/rest-api/src/middleware/README.md
Normal 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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
21
api/services/rest-api/src/services/auth-service.test.ts
Normal file
21
api/services/rest-api/src/services/auth-service.test.ts
Normal 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();
|
||||
// });
|
||||
});
|
||||
|
||||
216
api/services/rest-api/src/services/auth-service.ts
Normal file
216
api/services/rest-api/src/services/auth-service.ts
Normal 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();
|
||||
|
||||
31
api/services/rest-api/src/services/redis.ts
Normal file
31
api/services/rest-api/src/services/redis.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user