Skip to content

Commit

Permalink
feat: various improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
vlad-tkachenko committed Dec 13, 2023
1 parent 38f07c3 commit 6cd91d3
Show file tree
Hide file tree
Showing 34 changed files with 454 additions and 403 deletions.
16 changes: 12 additions & 4 deletions .env
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
LICENSE_CONSENT=true

#TLS_FILE_KEY=test/key.pem
#TLS_FILE_CERT=test/cert.pem
# TLS_FILE_KEY=test/key.pem
# TLS_STRING_STR_TEST=test
# TLS_NUMBER_NUMBER_TEST='1'

KC_TEST_USER=test
KC_TEST_USER_PASSWORD=test
Expand Down Expand Up @@ -36,12 +37,19 @@ MAPPINGS_WS='[

MAPPINGS_API='[
{
"pattern": "/api/.*",
"pattern": "^/api/.*$",
"methods": ["GET", "POST", "PUT", "DELETE"],
"auth": {
"claims": {
"realm": [ "test_role" ]
}
}
},
"exclude": [
{
"pattern": "/api/exclude/.*",
"methods": ["GET", "POST", "PUT", "DELETE"]
}
]
},
{
"pattern": "/api-optional/.*"
Expand Down
1 change: 1 addition & 0 deletions bin/1-test-keycloak.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ echoPID=$!
sleep 1

# run tests
export NODE_ENV=test
yarn test

kill $echoPID
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"keygen": "openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' -keyout test/key.pem -out test/cert.pem",
"test:keycloak": "bash bin/1-test-keycloak.sh",
"test:clean": "rimraf ./coverage && rm puppeteer-error-*",
"test": "export NODE_EXTRA_CA_CERTS=test/cert.pem && nyc --reporter=html --reporter=text mocha --inspect",
"test": "export NODE_EXTRA_CA_CERTS=test/cert.pem && nyc --reporter=html --reporter=text mocha",
"test:coverage": "nyc report --reporter=text-lcov > ./coverage/coverage.lcov"
},
"repository": {
Expand All @@ -46,7 +46,7 @@
"node-graceful-shutdown": "^1.1.5",
"openid-client": "^5.4.3",
"pino": "^8.14.1",
"prxi": "^1.2.1"
"prxi": "^1.2.2"
},
"devDependencies": {
"@testdeck/mocha": "^0.3.3",
Expand All @@ -56,7 +56,7 @@
"@types/mocha": "^10.0.1",
"@types/node": "^20.3.1",
"axios": "^1.4.0",
"dev-echo-server": "^0.2.0",
"dev-echo-server": "^0.2.1",
"mocha": "^10.2.0",
"mochawesome": "^7.1.3",
"nyc": "^15.1.0",
Expand Down
18 changes: 10 additions & 8 deletions src/Server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import 'dotenv/config';

import { Prxi } from 'prxi';
import { onShutdown } from "node-graceful-shutdown";
import { onShutdown } from 'node-graceful-shutdown';

import { getConfig, getSanitizedConfig } from "./config/getConfig";
import { getConfig, getSanitizedConfig } from './config/getConfig';

import getLogger from "./Logger";
import getLogger from './Logger';
import { CallbackHandler } from './handlers/http/CallbackHandler';
import { HealthHandler } from './handlers/http/HealthHandler';
import { ProxyHandler } from './handlers/http/ProxyHandler';
Expand All @@ -25,10 +25,9 @@ import { Http2CallbackHandler } from './handlers/http2/Http2CallbackHandler';
import { Http2ProxyHandler } from './handlers/http2/Http2ProxyHandler';
import { Debugger } from './utils/Debugger';
import { randomUUID } from 'crypto';
import { IncomingHttpHeaders } from 'http';
import { constants } from 'http2';
import { IncomingHttpHeaders } from 'node:http';
import { constants } from 'node:http2';
import { Console } from './utils/Console';
import { inspect } from 'util';

// Prepare logger

Expand All @@ -39,12 +38,13 @@ import { inspect } from 'util';
* @param testMode
* @returns
*/
export const start = async (testMode = false): Promise<Prxi> => {
export const start = async (): Promise<Prxi> => {
const logger = getLogger('Server');
const config = getConfig();

logger.child({config: getSanitizedConfig()}).debug('Configuration');

/* istanbul ignore next */
if (!config.licenseConsent) {
logger.error('###############################################################');
logger.error('# #');
Expand Down Expand Up @@ -75,6 +75,7 @@ export const start = async (testMode = false): Promise<Prxi> => {
requestId: context.requestId,
_: {mode, path, method}
}).info('Processing request - finished');
/* istanbul ignore else */
if (context.debugger.enabled) {
Console.printSolidBox(`[REQUEST] [${mode}] ${method}: ${path}`);
console.log(context.debugger.toString());
Expand All @@ -95,6 +96,7 @@ export const start = async (testMode = false): Promise<Prxi> => {
}
},
info(context, message, params) {
/* istanbul ignore next */
if (context.debugger) {
context.debugger.info(message, params);
} else {
Expand Down Expand Up @@ -188,7 +190,7 @@ export const start = async (testMode = false): Promise<Prxi> => {
await prxi.start();

/* istanbul ignore next */
if (!testMode) {
if (process.env.NODE_ENV !== 'test') {
onShutdown(async () => {
logger.info('Gracefully shutting down the server');
await prxi.stop();
Expand Down
2 changes: 1 addition & 1 deletion src/config/Config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Mapping } from "./Mapping";
import { Mapping } from './Mapping';

export interface Config {
licenseConsent: boolean;
Expand Down
14 changes: 10 additions & 4 deletions src/config/Mapping.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HttpMethod } from "prxi";
import { HttpMethod } from 'prxi';

export interface Mapping {
pattern: RegExp;
Expand All @@ -20,14 +20,17 @@ export interface Mapping {
*/
export const prepareMappings = (value: string): Mapping[] => {
const result: Mapping[] = [];
/* istanbul ignore else */
if (value) {
let json;
try {
json = JSON.parse(value);
} catch (e) {
/* istanbul ignore next */
throw new Error(`Invalid mapping, unable to parse json for value: ${value}`);
}

/* istanbul ignore next */
if (!Array.isArray(json)) {
throw new Error(`Invalid mapping, array expected instead of: ${value}`);
}
Expand All @@ -43,6 +46,7 @@ export const prepareMappings = (value: string): Mapping[] => {

const preparePattern = (value: {pattern?: string}): RegExp => {
let { pattern } = value;
/* istanbul ignore next */
if (!pattern) {
throw new Error(`Unable to parse mappings for value: ${JSON.stringify(value)}`);
}
Expand Down Expand Up @@ -83,20 +87,22 @@ export const prepareMapping = (value: any): Mapping => {

// if no claims set, set default object
if (!value.auth.claims || JSON.stringify(value.auth.claims) === '{}') {
/* istanbul ignore next */
if (value.auth.required) {
throw new Error(`Invalid mapping provided for pattern: ${value.pattern}, configuration will cause rejection of all requests. Either provide auth.claims or set auth.required flag to false`);
}

value.auth.claims = {};
}

return {
pattern,
methods: value.methods?.map((m: string) => m.toUpperCase()),
methods: value.methods && value.methods.map((m: string) => m.toUpperCase()),
auth: value.auth,
exclude: ([] || value.exclude).map((e: any) => {
exclude: (value.exclude || []).map((e: any) => {
return {
pattern: preparePattern(e),
methods: e.methods?.map((m: string) => m.toUpperCase()),
methods: e.methods && e.methods.map((m: string) => m.toUpperCase()),
}
})
}
Expand Down
31 changes: 16 additions & 15 deletions src/config/getConfig.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
import { prepareMappings } from "./Mapping";
import { Config } from "./Config";
import { readFileSync } from "fs";

let config: Config;
import { prepareMappings } from './Mapping';
import { Config } from './Config';
import { readFileSync } from 'node:fs';

/**
* Convert snake_case value to camelCase
* Convert snake_case to camelCase
* @param str
* @returns
*/
const snakeToCamelCase = (str: string): string => {
return str.toLowerCase().replace(/([-_][a-z])/g, (group) => {
return group
.toUpperCase()
.replace('-', '')
.replace('_', '')
});
export const snakeToCamelCase = (str: string) => {
return str.toLowerCase().replace(
/(_\w)/g,
(m: string) => {
return m.toUpperCase().substring(1);
}
);
}


let config: Config;

/**
* Get TLS secure settings
* @returns
*/
const getSecureSettings = (): Record<string, string | number | Buffer> | undefined => {
export const getSecureSettings = (): Record<string, string | number | Buffer> | undefined => {
const secure: Record<string, string | number | Buffer> = {};

for (const key in process.env) {
Expand All @@ -40,7 +41,7 @@ const getSecureSettings = (): Record<string, string | number | Buffer> | undefin
}

if (key.toUpperCase().indexOf('TLS_NUMBER_') === 0) {
const propName = snakeToCamelCase(key.toUpperCase().substring('TLS_FILE_'.length));
const propName = snakeToCamelCase(key.toUpperCase().substring('TLS_NUMBER_'.length));
secure[propName] = +process.env[key];
continue;
}
Expand Down
20 changes: 10 additions & 10 deletions src/handlers/WebsocketHandler.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { IncomingMessage } from "http";
import { Socket } from "net";
import { ProxyRequest, WebSocketHandlerConfig, WebSocketProxyCancelRequest } from "prxi";
import { Mapping } from "../config/Mapping";
import { getConfig } from "../config/getConfig";
import { RequestUtils } from "../utils/RequestUtils";
import { JwtPayload, verify } from "jsonwebtoken";
import { JWTVerificationResult, OpenIDUtils } from "../utils/OpenIDUtils";
import { Context } from "mocha";
import { Debugger } from "../utils/Debugger";
import { IncomingMessage } from 'node:http';
import { Socket } from 'net';
import { ProxyRequest, WebSocketHandlerConfig, WebSocketProxyCancelRequest } from 'prxi';
import { Mapping } from '../config/Mapping';
import { getConfig } from '../config/getConfig';
import { RequestUtils } from '../utils/RequestUtils';
import { JwtPayload, verify } from 'jsonwebtoken';
import { JWTVerificationResult, OpenIDUtils } from '../utils/OpenIDUtils';
import { Context } from 'mocha';
import { Debugger } from '../utils/Debugger';

export class WebSocketHandler implements WebSocketHandlerConfig {
/**
Expand Down
62 changes: 41 additions & 21 deletions src/handlers/http/CallbackHandler.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,42 @@
import { IncomingMessage, ServerResponse } from "http";
import { HttpMethod, ProxyRequest, HttpRequestHandlerConfig } from "prxi";
import { getConfig } from "../../config/getConfig";
import { sendErrorResponse, sendRedirect, setAuthCookies } from "../../utils/ResponseUtils";
import { OpenIDUtils } from "../../utils/OpenIDUtils";
import getLogger from "../../Logger";
import { RequestUtils } from "../../utils/RequestUtils";
import { IncomingMessage, ServerResponse } from 'node:http';
import { HttpMethod, ProxyRequest, HttpRequestHandlerConfig } from 'prxi';
import { getConfig } from '../../config/getConfig';
import { sendErrorResponse, sendRedirect, setAuthCookies } from '../../utils/ResponseUtils';
import { OpenIDUtils } from '../../utils/OpenIDUtils';
import { RequestUtils } from '../../utils/RequestUtils';
import { Context } from '../../types/Context';

export const CallbackHandler: HttpRequestHandlerConfig = {
isMatching: (method: HttpMethod, path: string) => {
return method === 'GET' && path === getConfig().openid.callbackPath;
/**
* @inheritdoc
*/
isMatching(method: HttpMethod, path: string, context: Context) {
const _ = context.debugger.child('CallbackHandler -> isMatching', {method, path});
const match = method === 'GET' && path === getConfig().openid.callbackPath;
_.debug('Matching result', {match});

return match;
},

handle: async (req: IncomingMessage, res: ServerResponse, proxyRequest: ProxyRequest) => {
const logger = getLogger('CallbackHandler');
/**
* @inheritdoc
*/
async handle(req: IncomingMessage, res: ServerResponse, proxyRequest: ProxyRequest, method: HttpMethod, path: string, context: Context) {
const _ = context.debugger.child('CallbackHandler -> handle', {method, path});
let tokens = await OpenIDUtils.exchangeCode(req);
_.debug('-> OpenIDUtils.exchangeCode()', { tokens });
let metaToken: string;

const cookies = RequestUtils.getCookies(req.headers);
_.debug('-> RequestUtils.getCookies()', { cookies });
const originalPath = cookies[getConfig().cookies.names.originalPath] || '/';
let redirectTo = `${getConfig().hostURL}${originalPath}`;

// login webhook handler (if any)
if (getConfig().webhook.login) {
logger.child({
webhookURL: getConfig().webhook.login
}).info('Making a webhook request upon login');
_.info('Making a webhook request upon login', {
webhookURL: getConfig().webhook.login,
});

const resp = await fetch(getConfig().webhook.login, {
method: 'POST',
Expand All @@ -38,35 +50,39 @@ export const CallbackHandler: HttpRequestHandlerConfig = {
});

if (!resp.ok) {
logger.child({status: resp.status}).error('Login webhook request failed');
_.error('Login webhook request failed', null, { statusCode: resp.status });
throw new Error('Unable to make a login webhook request');
}

const result = await resp.json();
_.debug('Login webhook request successful', { result });
// check if tokens should be refreshed (can be useful for the scenario when webhook endpoint modified user record and new JWT tokens needs to be issued with updated information)
if (result.refresh) {
tokens = await OpenIDUtils.refreshTokens(tokens.refresh_token);
_.debug('-> OpenIDUtils.refreshTokens()', { tokens });
}

// check if user access should be rejected (can be useful if webhook endpoint blocked user)
if (result.reject) {
logger.child({originalPath}).info('Webhook rejected the request');
_.info('Webhook rejected the request');
if (getConfig().redirect.pageRequest.e403) {
sendRedirect(req, res, getConfig().redirect.pageRequest.e403);
_.debug('Sending redirect response', {
url: getConfig().redirect.pageRequest.e403,
});
sendRedirect(_, req, res, getConfig().redirect.pageRequest.e403);
} else {
sendErrorResponse(req, 403, result.reason || 'Forbidden', res);
sendErrorResponse(_, req, 403, result.reason || 'Forbidden', res);
}

return;
}

if (result.meta) {
logger.child({meta: result.meta}).debug('Webhook returned custom user attributes');
_.debug('Webhook returned custom user attributes', { meta: result.meta });
metaToken = OpenIDUtils.prepareMetaToken(result.meta);
}

if (result.redirectTo) {
logger.child({redirectTo: result}).debug('Webhook returned custom redirect endpoint');
redirectTo = result.redirectTo;

// if relative path
Expand All @@ -79,10 +95,14 @@ export const CallbackHandler: HttpRequestHandlerConfig = {
}
redirectTo = `${getConfig().hostURL}${redirectTo}`;
}

_.debug('Webhook returned custom redirect endpoint', {
redirectTo,
});
}
}

setAuthCookies(res, tokens, metaToken);
await sendRedirect(req, res, redirectTo);
await sendRedirect(_, req, res, redirectTo);
}
}
Loading

0 comments on commit 6cd91d3

Please sign in to comment.