diff --git a/README.md b/README.md index 8382e77..a8b4b6b 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ DEFAULT_VC_POLICIES=expired,signature,revoked-status-list,not-before ENABLE_LOGS=1 MODE_PROXY=1 MODE_PS=1 +# Optional: if set, POST / requires X-API-Key to match this value +POLICY_SERVER_API_KEY=API_KEY_EXAMPLE ``` 1. Start the Docker container: @@ -36,6 +38,8 @@ MODE_PS=1 **Actions** +`POLICY_SERVER_API_KEY` is optional. If it is configured, requests to `POST /` must include `X-API-Key: `, and missing or invalid keys are rejected with `401 Unauthorized` before any action is processed. If it is not configured, the Policy Server accepts requests without API key authentication. + - `initiate` - `getPD` - `presentationRequest` @@ -1416,6 +1420,8 @@ WALTID_ERROR_REDIRECT_URL=https://example.com/error?id=$id ENABLE_LOGS=1 MODE_PROXY=1 MODE_PS=1 +# Optional: if set, POST / requires X-API-Key to match this value +POLICY_SERVER_API_KEY=API_KEY_EXAMPLE WALTID_VERIFY_RESPONSE_REDIRECT_URL=http://ocean-node-vm2.oceanenterprise.io:8100/verify/$id WALTID_VERIFY_PRESENTATION_DEFINITION_URL=http://ocean-node-vm2.oceanenterprise.io:8100/pd/$id DEFAULT_VP_POLICIES=expired,signature,revoked-status-list,not-before diff --git a/src/index.ts b/src/index.ts index 5933a11..3de9545 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,53 +10,73 @@ import { } from './utils/verifyPresentationRequest.js' import { downloadLogs } from './utils/logger.js' import dotenv from 'dotenv' +import path from 'path' +import { fileURLToPath } from 'url' +import { policyServerApiKeyAuth } from './utils/auth.js' + dotenv.config() -const app = express() -const authType = process.env.AUTH_TYPE || 'waltid' -async function handlePolicyRequest( - req: Request<{}, {}, PolicyRequestPayload>, - res: Response -): Promise { - const { action, ...rest } = req.body - - const handler = PolicyHandlerFactory.createPolicyHandler(authType) - if (handler == null) { - res.status(404).json({ - success: false, - status: 404, - message: `Handler for auth type "${authType}" is not found.` - }) + +export function createApp(): express.Express { + const app = express() + const authType = process.env.AUTH_TYPE || 'waltid' + + async function handlePolicyRequest( + req: Request<{}, {}, PolicyRequestPayload>, + res: Response + ): Promise { + const { action, ...rest } = req.body + + const handler = PolicyHandlerFactory.createPolicyHandler(authType) + if (handler == null) { + res.status(404).json({ + success: false, + httpStatus: 404, + message: `Handler for auth type "${authType}" is not found.` + }) + return + } + + const payload: PolicyRequestPayload = { action, ...rest } + const response: PolicyRequestResponse = await handler.execute(payload) + res.status(response.httpStatus).json(response) } - const payload: PolicyRequestPayload = { action, ...rest } - const response: PolicyRequestResponse = await handler.execute(payload) - res.status(response.httpStatus).json(response) -} + app.use(express.json()) -app.use(express.json()) -app.use(requestLogger) -if (process.env.MODE_PS && process.env.MODE_PS === '1') { - app.post('/', asyncHandler(handlePolicyRequest)) -} -if ( - process.env.OCEAN_NODE_URL && - process.env.MODE_PROXY && - process.env.MODE_PROXY === '1' -) { - app.post( - '/verify/:id', - express.urlencoded({ extended: true }), - asyncHandler(handleVerifyPresentationRequest) - ) - app.get('/pd/:id', asyncHandler(handleGetPD)) -} -if (process.env.ENABLE_LOGS && process.env.ENABLE_LOGS === '1') { - app.get('/logs', downloadLogs) + if (process.env.MODE_PS === '1') { + app.post( + '/', + policyServerApiKeyAuth, + requestLogger, + asyncHandler(handlePolicyRequest) + ) + } + if (process.env.OCEAN_NODE_URL && process.env.MODE_PROXY === '1') { + app.post( + '/verify/:id', + express.urlencoded({ extended: true }), + requestLogger, + asyncHandler(handleVerifyPresentationRequest) + ) + app.get('/pd/:id', requestLogger, asyncHandler(handleGetPD)) + } + if (process.env.ENABLE_LOGS === '1') { + app.get('/logs', requestLogger, downloadLogs) + } + app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDoc)) + app.use(errorHandler) + + return app } -app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDoc)) -app.use(errorHandler) -const PORT = process.env.PORT || 3000 -app.listen(PORT, () => { - console.log(`Server is running on port ${PORT}`) -}) +export const app = createApp() + +const currentModulePath = fileURLToPath(import.meta.url) +const entrypointPath = process.argv[1] ? path.resolve(process.argv[1]) : '' + +if (entrypointPath === currentModulePath) { + const PORT = process.env.PORT || 3000 + app.listen(PORT, () => { + console.log(`Server is running on port ${PORT}`) + }) +} diff --git a/src/test/.env.test b/src/test/.env.test index 5be795f..93af377 100644 --- a/src/test/.env.test +++ b/src/test/.env.test @@ -9,4 +9,5 @@ DEFAULT_VP_POLICIES=expired,signature,revoked-status-list,not-before DEFAULT_VC_POLICIES=expired,signature,revoked-status-list,not-before ENABLE_LOGS=1 MODE_PROXY=1 -MODE_PS=1 \ No newline at end of file +MODE_PS=1 +POLICY_SERVER_API_KEY=test-secret diff --git a/src/test/WaltIdPolicyHandler.test.ts b/src/test/WaltIdPolicyHandler.test.ts index a5523a0..c1996ce 100644 --- a/src/test/WaltIdPolicyHandler.test.ts +++ b/src/test/WaltIdPolicyHandler.test.ts @@ -2,8 +2,16 @@ import { expect } from 'chai' import sinon from 'sinon' import axios from 'axios' +import dotenv from 'dotenv' +import path from 'path' +import { fileURLToPath } from 'url' import { WaltIdPolicyHandler } from '../handlers/waltIdPolicyHandler.js' +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +dotenv.config({ path: path.join(__dirname, '.env.test') }) + describe('WaltIdPolicyHandler', () => { let handler: WaltIdPolicyHandler diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..4dd94ef --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,61 @@ +import { timingSafeEqual } from 'crypto' +import { Request, Response, NextFunction, RequestHandler } from 'express' +import { logWarn } from './logger.js' + +const API_KEY_HEADER = 'x-api-key' +const REDACTED_VALUE = '[REDACTED]' +const SENSITIVE_HEADERS = new Set(['x-api-key', 'authorization', 'cookie', 'set-cookie']) + +export function redactSensitiveHeaders( + headers: Request['headers'] +): Record { + return Object.entries(headers).reduce>( + (sanitizedHeaders, [key, value]) => { + sanitizedHeaders[key] = SENSITIVE_HEADERS.has(key.toLowerCase()) + ? REDACTED_VALUE + : value + return sanitizedHeaders + }, + {} + ) +} + +function isApiKeyValid(providedApiKey: string, expectedApiKey: string): boolean { + const providedBuffer = Buffer.from(providedApiKey) + const expectedBuffer = Buffer.from(expectedApiKey) + + if (providedBuffer.length !== expectedBuffer.length) return false + + return timingSafeEqual(providedBuffer, expectedBuffer) +} + +export const policyServerApiKeyAuth: RequestHandler = ( + req: Request, + res: Response, + next: NextFunction +): void => { + const expectedApiKey = process.env.POLICY_SERVER_API_KEY?.trim() + + if (!expectedApiKey) { + next() + return + } + + const providedApiKey = req.get(API_KEY_HEADER) + if (!providedApiKey || !isApiKeyValid(providedApiKey, expectedApiKey)) { + logWarn({ + method: req.method, + url: req.originalUrl, + headers: redactSensitiveHeaders(req.headers), + message: 'Policy Server API key authentication failed.' + }) + res.status(401).json({ + success: false, + message: 'Unauthorized', + httpStatus: 401 + }) + return + } + + next() +} diff --git a/src/utils/middleware.ts b/src/utils/middleware.ts index 258c02c..9bd5a14 100644 --- a/src/utils/middleware.ts +++ b/src/utils/middleware.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction, RequestHandler } from 'express' import axios, { AxiosError } from 'axios' import { PolicyRequestResponse } from '../@types/policy' import { logError, logInfo } from './logger.js' +import { redactSensitiveHeaders } from './auth.js' export const asyncHandler = (fn: RequestHandler): RequestHandler => @@ -12,7 +13,7 @@ export const requestLogger = (req: Request, res: Response, next: NextFunction): const logMessage = { method: req.method, url: req.originalUrl, - headers: req.headers, + headers: redactSensitiveHeaders(req.headers), body: req.body } @@ -32,7 +33,7 @@ export const errorHandler = ( const logMessage = { method: req.method, url: req.originalUrl, - headers: req.headers, + headers: redactSensitiveHeaders(req.headers), body: req.body, error: { message: err.message, diff --git a/swagger.json b/swagger.json index 8edceff..67e4528 100644 --- a/swagger.json +++ b/swagger.json @@ -9,6 +9,17 @@ "/": { "post": { "summary": "Perform an action", + "parameters": [ + { + "in": "header", + "name": "X-API-Key", + "required": false, + "schema": { + "type": "string" + }, + "description": "Optional shared secret header. Required only when POLICY_SERVER_API_KEY is configured on the Policy Server." + } + ], "requestBody": { "required": true, "content": { @@ -1218,6 +1229,9 @@ } } } + }, + "401": { + "description": "Missing or invalid API key." } } }