From edf107f7f3f1dd7a9fbccf5e9f5db03f876c5adb Mon Sep 17 00:00:00 2001 From: Vladyslav Tkachenko Date: Fri, 15 Dec 2023 14:48:01 +0200 Subject: [PATCH 1/4] feat: add permissions api endpoint --- .env | 1 + docs/API.md | 39 +++ package.json | 5 +- src/Server.ts | 12 +- src/config/Config.ts | 1 + src/config/getConfig.ts | 1 + src/handlers/http/ProxyHandler.ts | 18 +- src/handlers/http/api/BaseAccessHandler.ts | 62 +++++ .../http/api/PermissionsAPIHandler.ts | 116 +++++++++ src/handlers/http/api/WhoamiAPIHandler.ts | 41 +--- .../http2/api/Http2BaseAccessHandler.ts | 72 ++++++ .../http2/api/Http2PermissionsAPIHandler.ts | 138 +++++++++++ .../http2/api/Http2WhoamiAPIHandler.ts | 43 +--- src/types/Context.ts | 2 + src/utils/RequestUtils.ts | 36 ++- src/utils/ResponseUtils.ts | 3 + test/ApiMapping.suite.ts | 16 +- test/Base.suite.ts | 72 +++++- test/ErrorHandler.suite.ts | 8 +- test/PageMapping.suite.ts | 6 +- test/PermissionsAPIHandler.suite.ts | 161 ++++++++++++ test/WebhookHandler.suite.ts | 44 ++-- test/WhoamiAPIHandler.suite.ts | 27 +- test/helpers/FetchHelper.ts | 232 ++++++++++++++++++ yarn.lock | 17 +- 25 files changed, 1025 insertions(+), 148 deletions(-) create mode 100644 src/handlers/http/api/BaseAccessHandler.ts create mode 100644 src/handlers/http/api/PermissionsAPIHandler.ts create mode 100644 src/handlers/http2/api/Http2BaseAccessHandler.ts create mode 100644 src/handlers/http2/api/Http2PermissionsAPIHandler.ts create mode 100644 test/PermissionsAPIHandler.suite.ts create mode 100644 test/helpers/FetchHelper.ts diff --git a/.env b/.env index ccf9bc6..0984405 100644 --- a/.env +++ b/.env @@ -106,6 +106,7 @@ JWT_META_TOKEN_SECRET=abc HEADERS_META=x-prxi-meta WHOAMI_API_PATH=/_prxi/whoami +PERMISSIONS_API_PATH=/_prxi/permissions # For AWS Cognito: # OPENID_CONNECT_DISCOVER_URL=https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/openid-configuration diff --git a/docs/API.md b/docs/API.md index b24fd39..a747f9c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -42,3 +42,42 @@ When user is not authenticated API response will look like the following: } } ``` + +## Permissions + +An API endpoint that allows to check user permissions on specific resources. + +Environment variable: `PERMISSIONS_API_PATH`, example: `/_/api/whoami` + +`POST` request with an array of interested resources is expected in the body: + +```json +[ + { + // resource path + "path": "/a/b/c", + // resource method, GET, PUT, POST, PATCH, DELETE + "method": "GET" + } +] +``` + +Response: + +```json +{ + // flag to determine if user is authenticated or not + "anonymous": true, + // list of the resources included in the request + "resources": [ + { + // access allowance flag + "allowed": true, + // resource path + "path": "/a/b/c", + // resource method + "method": "GET" + } + ] +} +``` diff --git a/package.json b/package.json index 62d87fa..cdffeb7 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@vrbo/pino-rotating-file": "^4.4.0", + "body-parser": "^1.20.2", "cookie": "^0.6.0", "dotenv": "^16.3.1", "jsonwebtoken": "^9.0.1", @@ -46,10 +47,12 @@ "node-graceful-shutdown": "^1.1.5", "openid-client": "^5.4.3", "pino": "^8.14.1", - "prxi": "^1.2.2" + "prxi": "^1.2.2", + "raw-body": "^2.5.2" }, "devDependencies": { "@testdeck/mocha": "^0.3.3", + "@types/body-parser": "^1.19.5", "@types/cookie": "^0.5.4", "@types/jsonwebtoken": "^9.0.2", "@types/jwk-to-pem": "^2.0.1", diff --git a/src/Server.ts b/src/Server.ts index ff6cce1..05d3abc 100644 --- a/src/Server.ts +++ b/src/Server.ts @@ -30,6 +30,8 @@ import { constants } from "node:http2"; import { Console } from "./utils/Console"; import { WhoamiAPIHandler } from "./handlers/http/api/WhoamiAPIHandler"; import { Http2WhoamiAPIHandler } from './handlers/http2/api/Http2WhoamiAPIHandler'; +import { PermissionsAPIHandler } from './handlers/http/api/PermissionsAPIHandler'; +import { Http2PermissionsAPIHandler } from './handlers/http2/api/Http2PermissionsAPIHandler'; /** * Start server @@ -60,9 +62,15 @@ export const start = async (): Promise => { // Before request hook const beforeRequest = (mode: string, method: string, path: string, headers: IncomingHttpHeaders, context: Record) => { + let enabled = isDebug; + /* istanbul ignore else */ + if (isDebug && process.env.NODE_ENV === 'test' && path === '/favicon.ico') { + enabled = false; + } + const requestId = (headers['x-correlation-id'] || headers['x-trace-id'] || headers['x-request-id'] || randomUUID()).toString(); context.requestId = requestId; - context.debugger = new Debugger('Root', context.sessionId, requestId, isDebug); + context.debugger = new Debugger('Root', context.sessionId, requestId, enabled); logger.child({ requestId, _: {mode, path: path.split('?')[0], method} }).info('Processing request - start'); } @@ -165,6 +173,7 @@ export const start = async (): Promise => { new LoginHandler(), new LogoutHandler(), new WhoamiAPIHandler(), + new PermissionsAPIHandler(), CallbackHandler, new ProxyHandler(), E404Handler, @@ -174,6 +183,7 @@ export const start = async (): Promise => { new Http2LoginHandler(), new Http2LogoutHandler(), new Http2WhoamiAPIHandler(), + new Http2PermissionsAPIHandler(), Http2CallbackHandler, new Http2ProxyHandler(), Http2E404Handler, diff --git a/src/config/Config.ts b/src/config/Config.ts index b0a5604..e989e3d 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -19,6 +19,7 @@ export interface Config { login: string; api: { whoami?: string; + permissions?: string; } } diff --git a/src/config/getConfig.ts b/src/config/getConfig.ts index d70fd2d..e4fca71 100644 --- a/src/config/getConfig.ts +++ b/src/config/getConfig.ts @@ -75,6 +75,7 @@ export const getConfig = () => { login: process.env.LOGIN_PATH || '/_prxi_/login', api: { whoami: process.env.WHOAMI_API_PATH, + permissions: process.env.PERMISSIONS_API_PATH, } }, diff --git a/src/handlers/http/ProxyHandler.ts b/src/handlers/http/ProxyHandler.ts index fadf19e..422baf2 100644 --- a/src/handlers/http/ProxyHandler.ts +++ b/src/handlers/http/ProxyHandler.ts @@ -1,12 +1,12 @@ -import { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from "node:http"; -import { HttpMethod, ProxyRequest, HttpRequestHandlerConfig } from "prxi"; -import { sendErrorResponse, sendRedirect } from "../../utils/ResponseUtils"; -import { getConfig } from "../../config/getConfig"; -import { JwtPayload, verify } from "jsonwebtoken"; -import { RequestUtils } from "../../utils/RequestUtils"; -import { Context } from "../../types/Context"; -import { Debugger } from "../../utils/Debugger"; -import { handleHttpAuthenticationFlow } from "../../utils/AccessUtils"; +import { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'; +import { HttpMethod, ProxyRequest, HttpRequestHandlerConfig } from 'prxi'; +import { sendErrorResponse, sendRedirect } from '../../utils/ResponseUtils'; +import { getConfig } from '../../config/getConfig'; +import { JwtPayload, verify } from 'jsonwebtoken'; +import { RequestUtils } from '../../utils/RequestUtils'; +import { Context } from '../../types/Context'; +import { Debugger } from '../../utils/Debugger'; +import { handleHttpAuthenticationFlow } from '../../utils/AccessUtils'; export class ProxyHandler implements HttpRequestHandlerConfig { /** diff --git a/src/handlers/http/api/BaseAccessHandler.ts b/src/handlers/http/api/BaseAccessHandler.ts new file mode 100644 index 0000000..6aa501d --- /dev/null +++ b/src/handlers/http/api/BaseAccessHandler.ts @@ -0,0 +1,62 @@ +import { HttpMethod, HttpRequestHandlerConfig, ProxyRequest, Request, Response } from "prxi"; +import { Context } from "../../../types/Context"; +import { getConfig } from "../../../config/getConfig"; +import { RequestUtils } from "../../../utils/RequestUtils"; +import { JwtPayload, verify } from "jsonwebtoken"; +import { handleHttpAuthenticationFlow } from "../../../utils/AccessUtils"; + +export abstract class BaseAccessHandler implements HttpRequestHandlerConfig { + /** + * @inheritdoc + */ + abstract isMatching(method: HttpMethod, path: string, context: Context): boolean; + + /** + * @inheritdoc + */ + async handle(req: Request, res: Response, proxyRequest: ProxyRequest, method: HttpMethod, path: string, context: Context): Promise { + context.api = true; + + const _ = context.debugger.child('BaseAccessHandler -> handle()', { context, headers: req.headers, method, path }); + const cookies = RequestUtils.getCookies(req.headers); + _.debug('-> RequestUtils.getCookies()', { cookies }); + + let metaPayload: Record = null; + const metaToken = cookies[getConfig().cookies.names.meta]; + if (metaToken) { + metaPayload = verify(metaToken, getConfig().jwt.metaTokenSecret, { + complete: false, + }); + _.debug('Meta cookie found', { metaPayload }); + } + context.metaPayload = metaPayload?.p; + + const breakFlow = await handleHttpAuthenticationFlow( + _.child('-> handleAuthenticationFlow()'), + cookies, + req, + res, + method, + path, + context, + metaPayload + ); + if (breakFlow) { + _.debug('Breaking upon authentication'); + return; + } + + await this.process(req, res, proxyRequest, method, path, context); + } + + /** + * Called after handle() + * @param req + * @param res + * @param proxyRequest + * @param method + * @param path + * @param context + */ + abstract process(req: Request, res: Response, proxyRequest: ProxyRequest, method: HttpMethod, path: string, context: Context): Promise; +} diff --git a/src/handlers/http/api/PermissionsAPIHandler.ts b/src/handlers/http/api/PermissionsAPIHandler.ts new file mode 100644 index 0000000..d924a97 --- /dev/null +++ b/src/handlers/http/api/PermissionsAPIHandler.ts @@ -0,0 +1,116 @@ +import { HttpMethod, ProxyRequest, Request, Response } from "prxi"; +import { Context } from "../../../types/Context"; +import { getConfig } from "../../../config/getConfig"; +import { RequestUtils } from "../../../utils/RequestUtils"; +import { BaseAccessHandler } from "./BaseAccessHandler"; +import { sendJsonResponse } from "../../../utils/ResponseUtils"; +import { Mapping } from "../../../config/Mapping"; + +interface Resource { + path: string; + method: HttpMethod; + allowed: boolean; +} + +export class PermissionsAPIHandler extends BaseAccessHandler { + /** + * @inheritdoc + */ + isMatching(method: HttpMethod, path: string, context: Context): boolean { + return RequestUtils.isMatching( + context.debugger.child('PermissionsAPIHandler -> isMatching()', {method, path}), + // request + method, path, + // expected + 'POST', getConfig().paths.api.permissions, + ); + } + + /** + * @inheritdoc + */ + async process(req: Request, res: Response, proxyRequest: ProxyRequest, method: HttpMethod, path: string, context: Context): Promise { + const _ = context.debugger.child('PermissionsAPIHandler -> process()', { context, headers: req.headers, method, path }); + + // parse body + const body: Resource[] = await RequestUtils.readJsonBody(req); + + // validate + if (!body) { + return sendJsonResponse(_, 400, { + error: 'body is missing', + }, res); + } + + if (!Array.isArray(body)) { + return sendJsonResponse(_, 400, { + error: 'body is not an array', + }, res); + } + + for (const r of body) { + if (!r) { + return sendJsonResponse(_, 400, { + error: 'one of the body array elements is missing', + }, res); + } + + if (!r.path) { + return sendJsonResponse(_, 400, { + error: 'one of the body array elements is missing "path" property', + }, res); + } + + if (!r.method) { + return sendJsonResponse(_, 400, { + error: 'one of the body array elements is missing "method" property', + }, res); + } + } + + // process + const mappings = [ + getConfig().mappings.public, + getConfig().mappings.api, + getConfig().mappings.pages, + ]; + + const resources: Resource[] = []; + for (const r of body) { + let mapping: Mapping; + for (let m of mappings) { + mapping = RequestUtils.findMapping( + _, + m, + r.method, + r.path + ); + + if (mapping) { + break; + } + } + + let allowed = !!mapping; + if (allowed) { + allowed = !!RequestUtils.isAllowedAccess( + _.child('RequestUtils'), + context.accessTokenJWT, + context.idTokenJWT, + mapping, + ); + } + + resources.push({ + method: r.method, + path: r.path, + allowed, + }); + } + + await sendJsonResponse(_, 200, { + anonymous: !context.accessTokenJWT, + resources, + }, res); + } +} diff --git a/src/handlers/http/api/WhoamiAPIHandler.ts b/src/handlers/http/api/WhoamiAPIHandler.ts index 6d4fd90..eb097d0 100644 --- a/src/handlers/http/api/WhoamiAPIHandler.ts +++ b/src/handlers/http/api/WhoamiAPIHandler.ts @@ -1,12 +1,11 @@ -import { HttpMethod, HttpRequestHandlerConfig, ProxyRequest, Request, Response } from "prxi"; +import { HttpMethod, ProxyRequest, Request, Response } from "prxi"; import { Context } from "../../../types/Context"; import { getConfig } from "../../../config/getConfig"; import { RequestUtils } from "../../../utils/RequestUtils"; -import { JwtPayload, verify } from "jsonwebtoken"; -import { handleHttpAuthenticationFlow } from "../../../utils/AccessUtils"; import { sendJsonResponse } from "../../../utils/ResponseUtils"; +import { BaseAccessHandler } from "./BaseAccessHandler"; -export class WhoamiAPIHandler implements HttpRequestHandlerConfig { +export class WhoamiAPIHandler extends BaseAccessHandler { /** * @inheritdoc */ @@ -23,36 +22,8 @@ export class WhoamiAPIHandler implements HttpRequestHandlerConfig { /** * @inheritdoc */ - async handle(req: Request, res: Response, proxyRequest: ProxyRequest, method: HttpMethod, path: string, context: Context): Promise { - context.api = true; - - const _ = context.debugger.child('WhoamiAPIHandler -> handle()', { context, headers: req.headers, method, path }); - const cookies = RequestUtils.getCookies(req.headers); - _.debug('-> RequestUtils.getCookies()', { cookies }); - - let metaPayload: Record = null; - const metaToken = cookies[getConfig().cookies.names.meta]; - if (metaToken) { - metaPayload = verify(metaToken, getConfig().jwt.metaTokenSecret, { - complete: false, - }); - _.debug('Meta cookie found', { metaPayload }); - } - - const breakFlow = await handleHttpAuthenticationFlow( - _.child('-> handleAuthenticationFlow()'), - cookies, - req, - res, - method, - path, - context, - metaPayload?.p - ); - if (breakFlow) { - _.debug('Breaking upon authentication'); - return; - } + async process(req: Request, res: Response, proxyRequest: ProxyRequest, method: HttpMethod, path: string, context: Context): Promise { + const _ = context.debugger.child('WhoamiAPIHandler -> process()', { context, headers: req.headers, method, path }); const auth = RequestUtils.extractAuthJWTClaims([ context.accessTokenJWT, @@ -72,7 +43,7 @@ export class WhoamiAPIHandler implements HttpRequestHandlerConfig { auth, proxy, }, - meta: metaPayload?.p, + meta: context.metaPayload }, res); } } diff --git a/src/handlers/http2/api/Http2BaseAccessHandler.ts b/src/handlers/http2/api/Http2BaseAccessHandler.ts new file mode 100644 index 0000000..0019135 --- /dev/null +++ b/src/handlers/http2/api/Http2BaseAccessHandler.ts @@ -0,0 +1,72 @@ +import { HttpMethod, Http2RequestHandlerConfig, ProxyRequest } from "prxi"; +import { Context } from "../../../types/Context"; +import { getConfig } from "../../../config/getConfig"; +import { RequestUtils } from "../../../utils/RequestUtils"; +import { JwtPayload, verify } from "jsonwebtoken"; +import { handleHttp2AuthenticationFlow } from "../../../utils/AccessUtils"; +import { IncomingHttpHeaders, ServerHttp2Stream } from "http2"; + +export abstract class Http2BaseAccessHandler implements Http2RequestHandlerConfig { + /** + * @inheritdoc + */ + abstract isMatching(method: HttpMethod, path: string, context: Context): boolean; + + /** + * @inheritdoc + */ + async handle(stream: ServerHttp2Stream, headers: IncomingHttpHeaders, proxyRequest: ProxyRequest, method: HttpMethod, path: string, context: Context) { + context.api = true; + + const _ = context.debugger.child('Http2BaseAccessHandler -> handle()', { context, headers, method, path }); + const cookies = RequestUtils.getCookies(headers); + _.debug('-> RequestUtils.getCookies()', { cookies }); + + let metaPayload: Record = null; + const metaToken = cookies[getConfig().cookies.names.meta]; + if (metaToken) { + metaPayload = verify(metaToken, getConfig().jwt.metaTokenSecret, { + complete: false, + }); + _.debug('Meta cookie found', { metaPayload }); + context.metaPayload = metaPayload?.p; + } + + let { reject: breakFlow, cookiesToSet} = await handleHttp2AuthenticationFlow( + _.child('-> handleAuthenticationFlow()'), + stream, + headers, + cookies, + method, + path, + context, + metaPayload + ); + if (breakFlow) { + _.debug('Breaking upon authentication'); + return; + } + + await this.process(stream, headers, proxyRequest, method, path, context, cookiesToSet); + } + + /** + * Called after handle() + * @param stream + * @param headers + * @param proxyRequest + * @param method + * @param path + * @param context + * @param cookiesToSet + */ + abstract process( + stream: ServerHttp2Stream, + headers: IncomingHttpHeaders, + proxyRequest: ProxyRequest, + method: HttpMethod, + path: string, + context: Context, + cookiesToSet?: Record + ): Promise; +} diff --git a/src/handlers/http2/api/Http2PermissionsAPIHandler.ts b/src/handlers/http2/api/Http2PermissionsAPIHandler.ts new file mode 100644 index 0000000..bdcaee6 --- /dev/null +++ b/src/handlers/http2/api/Http2PermissionsAPIHandler.ts @@ -0,0 +1,138 @@ +import { HttpMethod, ProxyRequest } from "prxi"; +import { Context } from "../../../types/Context"; +import { getConfig } from "../../../config/getConfig"; +import { RequestUtils } from "../../../utils/RequestUtils"; +import { Http2BaseAccessHandler } from "./Http2BaseAccessHandler"; +import { sendJsonResponse } from "../../../utils/Http2ResponseUtils"; +import { Mapping } from "../../../config/Mapping"; +import { IncomingHttpHeaders, ServerHttp2Stream } from "http2"; +import { prepareSetCookies } from "../../../utils/ResponseUtils"; + +interface Resource { + path: string; + method: HttpMethod; + allowed: boolean; +} + +export class Http2PermissionsAPIHandler extends Http2BaseAccessHandler { + /** + * @inheritdoc + */ + isMatching(method: HttpMethod, path: string, context: Context): boolean { + return RequestUtils.isMatching( + context.debugger.child('Http2PermissionsAPIHandler -> isMatching()', {method, path}), + // request + method, path, + // expected + 'POST', getConfig().paths.api.permissions, + ); + } + + /** + * @inheritdoc + */ + async process( + stream: ServerHttp2Stream, + headers: IncomingHttpHeaders, + proxyRequest: ProxyRequest, + method: HttpMethod, + path: string, + context: Context, + cookiesToSet?: Record + ): Promise { + const _ = context.debugger.child('Http2PermissionsAPIHandler -> process()', { context, headers, method, path }); + + // parse body + const body: Resource[] = await RequestUtils.readJsonBody(stream); + + // validate + if (!body) { + return sendJsonResponse(_, 400, { + error: 'body is missing', + }, stream, { + 'Set-Cookie': prepareSetCookies(cookiesToSet) + }); + } + + if (!Array.isArray(body)) { + return sendJsonResponse(_, 400, { + error: 'body is not an array', + }, stream, { + 'Set-Cookie': prepareSetCookies(cookiesToSet) + }); + } + + for (const r of body) { + if (!r) { + return sendJsonResponse(_, 400, { + error: 'one of the body array elements is missing', + }, stream, { + 'Set-Cookie': prepareSetCookies(cookiesToSet) + }); + } + + if (!r.path) { + return sendJsonResponse(_, 400, { + error: 'one of the body array elements is missing "path" property', + }, stream, { + 'Set-Cookie': prepareSetCookies(cookiesToSet) + }); + } + + if (!r.method) { + return sendJsonResponse(_, 400, { + error: 'one of the body array elements is missing "method" property', + }, stream, { + 'Set-Cookie': prepareSetCookies(cookiesToSet) + }); + } + } + + // process + const mappings = [ + getConfig().mappings.public, + getConfig().mappings.api, + getConfig().mappings.pages, + ]; + + const resources: Resource[] = []; + for (const r of body) { + let mapping: Mapping; + for (let m of mappings) { + mapping = RequestUtils.findMapping( + _, + m, + r.method, + r.path + ); + + if (mapping) { + break; + } + } + + let allowed = !!mapping; + if (allowed) { + allowed = !!RequestUtils.isAllowedAccess( + _.child('RequestUtils'), + context.accessTokenJWT, + context.idTokenJWT, + mapping, + ); + } + + resources.push({ + method: r.method, + path: r.path, + allowed, + }); + } + + await sendJsonResponse(_, 200, { + anonymous: !context.accessTokenJWT, + resources, + }, stream, { + 'Set-Cookie': prepareSetCookies(cookiesToSet) + }); + } +} diff --git a/src/handlers/http2/api/Http2WhoamiAPIHandler.ts b/src/handlers/http2/api/Http2WhoamiAPIHandler.ts index 02b18df..5f98ffa 100644 --- a/src/handlers/http2/api/Http2WhoamiAPIHandler.ts +++ b/src/handlers/http2/api/Http2WhoamiAPIHandler.ts @@ -7,8 +7,9 @@ import { handleHttp2AuthenticationFlow } from "../../../utils/AccessUtils"; import { sendJsonResponse } from "../../../utils/Http2ResponseUtils"; import { IncomingHttpHeaders, ServerHttp2Stream } from "http2"; import { prepareSetCookies } from "../../../utils/ResponseUtils"; +import { Http2BaseAccessHandler } from "./Http2BaseAccessHandler"; -export class Http2WhoamiAPIHandler implements Http2RequestHandlerConfig { +export class Http2WhoamiAPIHandler extends Http2BaseAccessHandler { /** * @inheritdoc */ @@ -25,36 +26,16 @@ export class Http2WhoamiAPIHandler implements Http2RequestHandlerConfig { /** * @inheritdoc */ - async handle(stream: ServerHttp2Stream, headers: IncomingHttpHeaders, proxyRequest: ProxyRequest, method: HttpMethod, path: string, context: Context) { - context.api = true; - + async process( + stream: ServerHttp2Stream, + headers: IncomingHttpHeaders, + proxyRequest: ProxyRequest, + method: HttpMethod, + path: string, + context: Context, + cookiesToSet?: Record + ) { const _ = context.debugger.child('Http2WhoamiAPIHandler -> handle()', { context, headers, method, path }); - const cookies = RequestUtils.getCookies(headers); - _.debug('-> RequestUtils.getCookies()', { cookies }); - - let metaPayload: Record = null; - const metaToken = cookies[getConfig().cookies.names.meta]; - if (metaToken) { - metaPayload = verify(metaToken, getConfig().jwt.metaTokenSecret, { - complete: false, - }); - _.debug('Meta cookie found', { metaPayload }); - } - - let { reject: breakFlow, cookiesToSet} = await handleHttp2AuthenticationFlow( - _.child('-> handleAuthenticationFlow()'), - stream, - headers, - cookies, - method, - path, - context, - metaPayload?.p - ); - if (breakFlow) { - _.debug('Breaking upon authentication'); - return; - } const auth = RequestUtils.extractAuthJWTClaims([ context.accessTokenJWT, @@ -74,7 +55,7 @@ export class Http2WhoamiAPIHandler implements Http2RequestHandlerConfig { auth, proxy, }, - meta: metaPayload?.p, + meta: context.metaPayload, }, stream, { 'Set-Cookie': prepareSetCookies(cookiesToSet) }); diff --git a/src/types/Context.ts b/src/types/Context.ts index 1c3b37d..9937c38 100644 --- a/src/types/Context.ts +++ b/src/types/Context.ts @@ -9,6 +9,8 @@ export interface Context { debugger: Debugger; + metaPayload?: unknown, + // ws proxy handler wsMapping: Mapping, diff --git a/src/utils/RequestUtils.ts b/src/utils/RequestUtils.ts index e739a44..dcc0dbd 100644 --- a/src/utils/RequestUtils.ts +++ b/src/utils/RequestUtils.ts @@ -1,10 +1,13 @@ import { parse, serialize } from "cookie"; import { IncomingHttpHeaders } from "node:http"; -import { Mapping } from "../config/Mapping"; -import { getConfig } from "../config/getConfig"; +import { ServerHttp2Stream } from "node:http2"; import { Jwt } from "jsonwebtoken"; +import { HttpMethod, Request } from "prxi"; +import * as getRawBody from "raw-body"; + import { Debugger } from "./Debugger"; -import { HttpMethod } from "prxi"; +import { Mapping } from "../config/Mapping"; +import { getConfig } from "../config/getConfig"; export class RequestUtils { /** @@ -256,4 +259,31 @@ export class RequestUtils { _.debug('No matches found'); return null; } + + /** + * Read JSON + * @param req + * @param res + * @returns + */ + static async readJsonBody(req: Request | ServerHttp2Stream): Promise { + return await new Promise((resolve, reject) => { + getRawBody(req, { + limit: 128 * 1024, + }, (err: Error, body: Buffer) => { + if (err) { + return reject(err); + } + + let json; + try { + json = JSON.parse(body.toString('utf-8')) + } catch (e) { + return reject(e); + } + + resolve(json); + }); + }); + } } diff --git a/src/utils/ResponseUtils.ts b/src/utils/ResponseUtils.ts index ae821a3..f1c24d2 100644 --- a/src/utils/ResponseUtils.ts +++ b/src/utils/ResponseUtils.ts @@ -242,5 +242,8 @@ export const prepareSetCookies = (cookies: Record { - await this.loginOnKeycloak(page); - await this.navigate(page, getConfig().hostURL + uri); + await this.withNewAuthenticatedPage(getConfig().hostURL + uri, async (page) => { const json = await this.getJsonFromPage(page); // validate query to be in place @@ -41,8 +39,7 @@ class BaseApiMappingSuite extends BaseSuite { async exclude() { const uri = '/api/exclude/test'; - await this.withNewPage(getConfig().hostURL + '/pages/test', async (page) => { - await this.loginOnKeycloak(page); + await this.withNewAuthenticatedPage(getConfig().hostURL + '/pages/test', async (page) => { await this.navigate(page, getConfig().hostURL + uri); const url = page.url(); @@ -57,8 +54,7 @@ class BaseApiMappingSuite extends BaseSuite { // add configuration for additional headers getConfig().cookies.proxyToUpstream = false; - await this.withNewPage(getConfig().hostURL + '/pages/test', async (page) => { - await this.loginOnKeycloak(page); + await this.withNewAuthenticatedPage(getConfig().hostURL + '/pages/test', async (page) => { await page.setCookie( { name: 'test1', @@ -100,8 +96,7 @@ class BaseApiMappingSuite extends BaseSuite { 'Cookie': null, } - await this.withNewPage(getConfig().hostURL + '/pages/test', async (page) => { - await this.loginOnKeycloak(page); + await this.withNewAuthenticatedPage(getConfig().hostURL + '/pages/test', async (page) => { await page.setCookie( { name: 'test1', @@ -150,8 +145,7 @@ class BaseApiMappingSuite extends BaseSuite { @test() async e403() { const uri = '/forbidden-api/test?q=str'; - await this.withNewPage(getConfig().hostURL + '/pages/test', async (page) => { - await this.loginOnKeycloak(page); + await this.withNewAuthenticatedPage(getConfig().hostURL + '/pages/test', async (page) => { await this.navigate(page, getConfig().hostURL + uri); const text = await this.getTextFromPage(page); diff --git a/test/Base.suite.ts b/test/Base.suite.ts index 6656ead..9aba0a0 100644 --- a/test/Base.suite.ts +++ b/test/Base.suite.ts @@ -2,13 +2,15 @@ import 'dotenv/config'; import { Config } from "../src/config/Config"; import { getConfig, updateConfig } from "../src/config/getConfig"; -import puppeteer, { Page } from "puppeteer"; +import puppeteer, { Page, Protocol } from "puppeteer"; import { start } from "../src/Server"; import { Prxi } from "prxi"; import { readFileSync } from "fs"; import { resolve } from "path"; import { context } from "@testdeck/mocha"; import { Console } from "../src/utils/Console"; +import { FetchHelper } from './helpers/FetchHelper'; +import { serialize } from 'cookie'; export class BaseSuite { private originalConfig: Config; @@ -122,6 +124,21 @@ export class BaseSuite { } } + /** + * Open page and set authentication cookies + * @param url + * @param handler + * @param beforeNavigate + */ + protected async withNewAuthenticatedPage(url: string, handler: (page: Page) => Promise, beforeNavigate?: (page: Page) => Promise): Promise { + const authCookies = await this.loginAndGetAuthCookies(); + await this.withNewPage(url, handler, async (page) => { + console.log('@AUTH COOKIES@', authCookies); + await page.setCookie(...authCookies); + await beforeNavigate?.(page); + }) + } + /** * Open page, navigate and call the handler * @param url @@ -239,4 +256,57 @@ export class BaseSuite { protected async getTextFromPage(page: Page): Promise { return await page.evaluate(() => document.querySelector('pre').innerHTML); } + + /** + * Sort nested arrays + * @param obj + */ + protected sortNestedArrays(obj: Record) { + for (const key of Object.keys(obj)) { + const field = obj[key]; + if (field) { + if (Array.isArray(field)) { + field.sort(); + } else { + if (typeof field === 'object') { + this.sortNestedArrays(field); + } + } + } + } + } + + /** + * Login and extract auth cookies from the page + * @returns + */ + protected async loginAndGetAuthCookies(): Promise { + let result: Protocol.Network.Cookie[] = []; + + await this.withNewPage(getConfig().hostURL + getConfig().paths.login, async (page) => { + await this.loginOnKeycloak(page); + const cookies = await page.cookies(); + result = cookies.filter(c => { + return [ + getConfig().cookies.names.accessToken, + getConfig().cookies.names.idToken, + getConfig().cookies.names.refreshToken, + ].indexOf(c.name) >= 0; + }) + }); + + return result; + } + + /** + * Make http post + * @param url + * @param body + * @param authCookies + */ + protected async makePost(url: string, body: any, authCookies?: Partial[]): Promise { + return new FetchHelper(this.mode, this.secure).post(url, body, { + 'cookie': authCookies ? authCookies.map(c => serialize(c.name, c.value)).join('; ') : undefined, + }) + } } diff --git a/test/ErrorHandler.suite.ts b/test/ErrorHandler.suite.ts index e7c5142..60cd6ec 100644 --- a/test/ErrorHandler.suite.ts +++ b/test/ErrorHandler.suite.ts @@ -11,9 +11,7 @@ class BaseErrorHandlerSuite extends BaseSuite { upstream: getConfig().upstream.replace(/:\/\/.*/g, '://localhost:65000'), }) - await this.withNewPage(getConfig().hostURL + '/pages/test', async (page) => { - await this.loginOnKeycloak(page); - + await this.withNewAuthenticatedPage(getConfig().hostURL + '/pages/test', async (page) => { const text = await this.getTextFromPage(page); strictEqual(text, '503: Service Unavailable') }); @@ -31,9 +29,7 @@ class BaseErrorHandlerSuite extends BaseSuite { } }) - await this.withNewPage(getConfig().hostURL + '/pages/test', async (page) => { - await this.loginOnKeycloak(page); - + await this.withNewAuthenticatedPage(getConfig().hostURL + '/pages/test', async (page) => { const json = await this.getJsonFromPage(page); strictEqual(json.http.url, '/api/test'); }); diff --git a/test/PageMapping.suite.ts b/test/PageMapping.suite.ts index a65fbee..06a06ee 100644 --- a/test/PageMapping.suite.ts +++ b/test/PageMapping.suite.ts @@ -8,8 +8,7 @@ class BasePageMappingSuite extends BaseSuite { @test() async pageEndpoint() { const uri = '/pages/test?q=str'; - await this.withNewPage(getConfig().hostURL + uri, async (page) => { - await this.loginOnKeycloak(page); + await this.withNewAuthenticatedPage(getConfig().hostURL + uri, async (page) => { const json = await this.getJsonFromPage(page); // validate query to be in place @@ -53,8 +52,7 @@ class BasePageMappingSuite extends BaseSuite { @test() async e403Endpoint() { const uri = '/forbidden-pages/test?q=str'; - await this.withNewPage(getConfig().hostURL + uri, async (page) => { - await this.loginOnKeycloak(page); + await this.withNewAuthenticatedPage(getConfig().hostURL + uri, async (page) => { const url = page.url(); ok(url.indexOf(getConfig().redirect.pageRequest.e403) === 0, `Actual URL: ${url}; Expected URL: ${getConfig().redirect.pageRequest.e403}`); }); diff --git a/test/PermissionsAPIHandler.suite.ts b/test/PermissionsAPIHandler.suite.ts new file mode 100644 index 0000000..ae6c6f6 --- /dev/null +++ b/test/PermissionsAPIHandler.suite.ts @@ -0,0 +1,161 @@ +import { suite, test } from "@testdeck/mocha"; +import { BaseSuite } from "./Base.suite"; +import { getConfig } from "../src/config/getConfig"; +import { deepEqual } from "assert"; +import { sign } from "jsonwebtoken"; + +abstract class BasePermissionsAPIHandlerSuite extends BaseSuite { + @test() + async authorized() { + const authCookies = await this.loginAndGetAuthCookies(); + const json = await this.makePost(getConfig().hostURL + getConfig().paths.api.permissions, [ + { + path: '/public/test', + method: 'GET', + }, + { + path: '/pages/auth/access', + method: 'GET', + }, + { + path: '/forbidden-pages/auth/pages', + method: 'GET', + }, + { + path: '/api/auth', + method: 'GET' + }, + { + path: '/api-optional/auth', + method: 'GET' + } + ], authCookies); + + deepEqual(json.data, { + anonymous: false, + resources: [ + { + path: '/public/test', + method: 'GET', + allowed: true, + }, + { + path: '/pages/auth/access', + method: 'GET', + allowed: true, + }, + { + path: '/forbidden-pages/auth/pages', + method: 'GET', + allowed: false, + }, + { + path: '/api/auth', + method: 'GET', + allowed: true, + }, + { + path: '/api-optional/auth', + method: 'GET', + allowed: true, + } + ], + }); + } + + @test() + async invalidAuth() { + const json = await this.makePost(getConfig().hostURL + getConfig().paths.api.permissions, [], [ + { + name: getConfig().cookies.names.accessToken, + value: sign({}, 'test') + } + ]); + + deepEqual(json.data, { + details: { + code: 401, + message: 'Unauthorized' + }, + error: true + }); + } + + @test() + async anonymous() { + const json = await this.makePost(getConfig().hostURL + getConfig().paths.api.permissions, [ + { + path: '/public/test', + method: 'GET', + }, + { + path: '/pages/auth/access', + method: 'GET', + }, + { + path: '/forbidden-pages/auth/pages', + method: 'GET', + }, + { + path: '/api/auth', + method: 'GET' + }, + { + path: '/api-optional/auth', + method: 'GET' + } + ]); + + deepEqual(json.data, { + anonymous: true, + resources: [ + { + path: '/public/test', + method: 'GET', + allowed: true, + }, + { + path: '/pages/auth/access', + method: 'GET', + allowed: false, + }, + { + path: '/forbidden-pages/auth/pages', + method: 'GET', + allowed: false, + }, + { + path: '/api/auth', + method: 'GET', + allowed: false, + }, + { + path: '/api-optional/auth', + method: 'GET', + allowed: true, + } + ], + }); + } +} + +@suite() +class HttpPermissionsAPIHandlerSuite extends BasePermissionsAPIHandlerSuite{ + constructor() { + super('HTTP', false); + } +} + +@suite() +class HttpsPermissionsAPIHandlerSuite extends BasePermissionsAPIHandlerSuite{ + constructor() { + super('HTTP', true); + } +} + +@suite() +class Http2PermissionsAPIHandlerSuite extends BasePermissionsAPIHandlerSuite{ + constructor() { + super('HTTP2', true); + } +} diff --git a/test/WebhookHandler.suite.ts b/test/WebhookHandler.suite.ts index bb2982b..e86bc32 100644 --- a/test/WebhookHandler.suite.ts +++ b/test/WebhookHandler.suite.ts @@ -5,17 +5,17 @@ import { strictEqual } from "assert"; const OpenApiMocker = require('open-api-mocker'); -class BasePublicMappingSuite extends BaseSuite { +class BaseWebhookHandlerSuite extends BaseSuite { private mockServer: any; private static mockPort = 7777; - private static rejectURL = `http://localhost:${BasePublicMappingSuite.mockPort}/reject`; - private static loginFailure = `http://localhost:${BasePublicMappingSuite.mockPort}/login-fail`; - private static redirectToURL = `http://localhost:${BasePublicMappingSuite.mockPort}/redirectTo`; - private static refreshToken = `http://localhost:${BasePublicMappingSuite.mockPort}/refreshToken`; - private static logout = `http://localhost:${BasePublicMappingSuite.mockPort}/logout`; - private static logoutFailure = `http://localhost:${BasePublicMappingSuite.mockPort}/logout-fail`; - private static metaURL = `http://localhost:${BasePublicMappingSuite.mockPort}/meta`; + private static rejectURL = `http://localhost:${BaseWebhookHandlerSuite.mockPort}/reject`; + private static loginFailure = `http://localhost:${BaseWebhookHandlerSuite.mockPort}/login-fail`; + private static redirectToURL = `http://localhost:${BaseWebhookHandlerSuite.mockPort}/redirectTo`; + private static refreshToken = `http://localhost:${BaseWebhookHandlerSuite.mockPort}/refreshToken`; + private static logout = `http://localhost:${BaseWebhookHandlerSuite.mockPort}/logout`; + private static logoutFailure = `http://localhost:${BaseWebhookHandlerSuite.mockPort}/logout-fail`; + private static metaURL = `http://localhost:${BaseWebhookHandlerSuite.mockPort}/meta`; public async before() { await this.initMockServer(); @@ -33,7 +33,7 @@ class BasePublicMappingSuite extends BaseSuite { */ private async initMockServer(): Promise { const mocker = this.mockServer = new OpenApiMocker({ - port: BasePublicMappingSuite.mockPort, + port: BaseWebhookHandlerSuite.mockPort, schema: 'test/assets/webhook/mock.yml', }); @@ -45,7 +45,7 @@ class BasePublicMappingSuite extends BaseSuite { async rejectLogin(): Promise { await this.reloadPrxiWith({ webhook: { - login: BasePublicMappingSuite.rejectURL, + login: BaseWebhookHandlerSuite.rejectURL, } }); @@ -66,7 +66,7 @@ class BasePublicMappingSuite extends BaseSuite { async rejectLoginWithoutRedirectConfig(): Promise { await this.reloadPrxiWith({ webhook: { - login: BasePublicMappingSuite.rejectURL, + login: BaseWebhookHandlerSuite.rejectURL, }, redirect: { pageRequest: { @@ -86,7 +86,7 @@ class BasePublicMappingSuite extends BaseSuite { async refreshToken(): Promise { await this.reloadPrxiWith({ webhook: { - login: BasePublicMappingSuite.refreshToken, + login: BaseWebhookHandlerSuite.refreshToken, } }); @@ -108,7 +108,7 @@ class BasePublicMappingSuite extends BaseSuite { await this.reloadPrxiWith({ webhook: { - login: BasePublicMappingSuite.redirectToURL, + login: BaseWebhookHandlerSuite.redirectToURL, } }); @@ -125,7 +125,7 @@ class BasePublicMappingSuite extends BaseSuite { async testLoginFailure(): Promise { await this.reloadPrxiWith({ webhook: { - login: BasePublicMappingSuite.loginFailure, + login: BaseWebhookHandlerSuite.loginFailure, } }); @@ -141,7 +141,7 @@ class BasePublicMappingSuite extends BaseSuite { async testLoginFailureWithE500Redirect(): Promise { await this.reloadPrxiWith({ webhook: { - login: BasePublicMappingSuite.loginFailure, + login: BaseWebhookHandlerSuite.loginFailure, }, redirect: { pageRequest: { @@ -162,7 +162,7 @@ class BasePublicMappingSuite extends BaseSuite { async testMeta(): Promise { await this.reloadPrxiWith({ webhook: { - login: BasePublicMappingSuite.metaURL, + login: BaseWebhookHandlerSuite.metaURL, } }); @@ -186,8 +186,8 @@ class BasePublicMappingSuite extends BaseSuite { async logoutEndpoint(): Promise { await this.reloadPrxiWith({ webhook: { - login: BasePublicMappingSuite.metaURL, - logout: BasePublicMappingSuite.logout, + login: BaseWebhookHandlerSuite.metaURL, + logout: BaseWebhookHandlerSuite.logout, } }); @@ -213,7 +213,7 @@ class BasePublicMappingSuite extends BaseSuite { async logoutFailEndpoint(): Promise { await this.reloadPrxiWith({ webhook: { - logout: BasePublicMappingSuite.logoutFailure, + logout: BaseWebhookHandlerSuite.logoutFailure, } }); @@ -237,21 +237,21 @@ class BasePublicMappingSuite extends BaseSuite { } @suite() -class HttpPublicMappingSuite extends BasePublicMappingSuite { +class HttpWebhookHandlerSuite extends BaseWebhookHandlerSuite { constructor() { super('HTTP', false); } } @suite() -class HttpsPublicMappingSuite extends BasePublicMappingSuite { +class HttpsWebhookHandlerSuite extends BaseWebhookHandlerSuite { constructor() { super('HTTP', true); } } @suite() -class Http2PublicMappingSuite extends BasePublicMappingSuite { +class Http2WebhookHandlerSuite extends BaseWebhookHandlerSuite { constructor() { super('HTTP2', true); } diff --git a/test/WhoamiAPIHandler.suite.ts b/test/WhoamiAPIHandler.suite.ts index 0084d47..93000a7 100644 --- a/test/WhoamiAPIHandler.suite.ts +++ b/test/WhoamiAPIHandler.suite.ts @@ -21,21 +21,6 @@ abstract class BaseWhoamiAPIHandlerSuite extends BaseSuite { await super.after(); } - private static sortNestedArrays(obj: Record) { - for (const key of Object.keys(obj)) { - const field = obj[key]; - if (field) { - if (Array.isArray(field)) { - field.sort(); - } else { - if (typeof field === 'object') { - BaseWhoamiAPIHandlerSuite.sortNestedArrays(field); - } - } - } - } - } - /** * Init mock server */ @@ -51,13 +36,9 @@ abstract class BaseWhoamiAPIHandlerSuite extends BaseSuite { @test() async authorized() { - await this.withNewPage(getConfig().hostURL + getConfig().paths.login, async (page) => { - await this.loginOnKeycloak(page); - - // make sure we can access the resource - await this.navigate(page, getConfig().hostURL + getConfig().paths.api.whoami); + await this.withNewAuthenticatedPage(getConfig().hostURL + getConfig().paths.api.whoami, async (page) => { const json = await this.getJsonFromPage(page); - BaseWhoamiAPIHandlerSuite.sortNestedArrays(json); + this.sortNestedArrays(json); deepEqual(json, { anonymous: false, @@ -103,7 +84,7 @@ abstract class BaseWhoamiAPIHandlerSuite extends BaseSuite { // make sure we can access the resource await this.navigate(page, getConfig().hostURL + getConfig().paths.api.whoami); const json = await this.getJsonFromPage(page); - BaseWhoamiAPIHandlerSuite.sortNestedArrays(json); + this.sortNestedArrays(json); deepEqual(json, { anonymous: false, @@ -165,7 +146,7 @@ abstract class BaseWhoamiAPIHandlerSuite extends BaseSuite { async anonymous() { await this.withNewPage(getConfig().hostURL + getConfig().paths.api.whoami, async (page) => { const json = await this.getJsonFromPage(page); - BaseWhoamiAPIHandlerSuite.sortNestedArrays(json); + this.sortNestedArrays(json); deepEqual(json, { anonymous: true, diff --git a/test/helpers/FetchHelper.ts b/test/helpers/FetchHelper.ts new file mode 100644 index 0000000..d2fcc5e --- /dev/null +++ b/test/helpers/FetchHelper.ts @@ -0,0 +1,232 @@ +import { connect, constants } from 'node:http2'; +import path = require('node:path'); + +export class FetchHelper { + constructor(private mode: 'HTTP' | 'HTTP2', private secure: boolean) {} + + public fixUrl(url: string): string { + if (!this.secure) { + return url; + } + + return url.replace(/http:\/\//i, 'https://').replace(/http:\/\//, 'wss://') + } + + /** + * Make GET request + * @param url + * @param headers + * @returns + */ + async get(url: string, headers: Record = {}): Promise<{ + data: any, + headers: Record, + }> { + url = this.fixUrl(url); + console.log(`-> [${this.mode}] Making GET request to ${url}`); + + if (this.mode === 'HTTP') { + return await this.getHttp1(url, headers); + } + + if (this.mode === 'HTTP2') { + return await this.getHttp2(url, headers); + } + + throw new Error(`Unable to make GET request for unhandled mode ${this.mode}`); + } + + /** + * Make HTTP/1.1 GET request + * @param url + * @param headers + * @returns + */ + private async getHttp1(url: string, headers: Record): Promise<{ + data: any, + headers: Record, + }> { + return await this.makeHttp1Request('GET', url, headers); + } + + /** + * Make HTTP/2 GET request + * @param url + * @param headers + * @returns + */ + private async getHttp2(url: string, headers: Record): Promise<{ + data: any, + headers: Record, + }> { + return await this.makeHttp2Request( + constants.HTTP2_METHOD_GET, + url, + headers, + ); + } + + /** + * Make POST request + * @param url + * @param data + * @param headers + * @returns + */ + async post(url: string, data: unknown, headers: Record = {}): Promise<{ + data: any, + headers: Record, + }> { + url = this.fixUrl(url); + console.log(`-> [${this.mode}] Making POST request to ${url}`); + + if (this.mode === 'HTTP') { + return await this.postHttp1(url, data, headers); + } + + if (this.mode === 'HTTP2') { + return await this.postHttp2(url, data, headers); + } + + throw new Error(`Unable to make POST request for unhandled mode ${this.mode}`); + } + + /** + * Make HTTP/1.1 POST request + * @param url + * @param data + * @param headers + * @returns + */ + private async postHttp1(url: string, data: unknown, headers: Record): Promise<{ + data: any, + headers: Record, + }> { + return await this.makeHttp1Request('POST', url, headers, data); + } + + /** + * Make HTTP/2 POST request + * @param url + * @param data + * @param headers + * @returns + */ + private async postHttp2(url: string, data: unknown, headers: Record): Promise<{ + data: any, + headers: Record, + }> { + return await this.makeHttp2Request( + constants.HTTP2_METHOD_POST, + url, + headers, + data + ); + } + + private async makeHttp1Request(method: string, url: string, headers: Record, data?: unknown): Promise { + try { + const makeRequest = async () => { + const response = await fetch(url, { + method, + headers: { + 'Connection': 'close', + 'content-type': 'application/json', + 'accept': 'application/json', + ...headers + }, + body: data ? JSON.stringify(data) : undefined, + }); + + const responseHeaders: Record = {}; + for (const header of response.headers.keys()) { + responseHeaders[header] = response.headers.get(header).toString(); + } + + return { + data: await response.json(), + headers: responseHeaders, + }; + } + + return await makeRequest(); + } catch (err) { + console.error(err); + throw err; + } + } + + private async makeHttp2Request(method: string, url: string, headers: Record, data?: unknown): Promise { + const buffer = data ? Buffer.from(JSON.stringify(data)) : undefined; + + return new Promise((res, rej) => { + let count = 0; + try { + const { origin, pathname, search } = new URL(url); + let client = connect(origin); + + client.once('close', () => { + console.log(`-> Connection closed`); + }) + + const makeRequest = () => { + console.log(`-> Making request`); + // if client closed, reconnect + if (client.closed) { + client.close(); + console.log(`-> Reconnecting for request`); + client = connect(origin); + } + + const req = client.request({ + [constants.HTTP2_HEADER_PATH]: `${pathname}${search}`, + [constants.HTTP2_HEADER_METHOD]: method, + 'content-type': 'application/json', + 'accept': 'application/json', + ...headers, + }); + + let responseHeaders: Record = {}; + req.once('response', (headers, flags) => { + for (const header of Object.keys(headers)) { + responseHeaders[header] = headers[header].toString(); + } + }); + + req.once('error', (err) => { + console.error('FetchHelper - req error', err); + rej(err); + }); + + req.setEncoding('utf8'); + let data = ''; + req.on('data', (chunk) => { + data += chunk; + }); + + req.once('end', () => { + client.close(); + + try { + res({ + data: data ? JSON.parse(data) : undefined, + headers: responseHeaders, + }); + } catch (e) { + rej(e); + } + }); + + if (buffer) { + req.write(buffer); + } + req.end(); + } + + makeRequest(); + } catch (e) { + rej(e); + } + }); + } +} diff --git a/yarn.lock b/yarn.lock index 3742eb5..d005ec7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -329,6 +329,21 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== +"@types/body-parser@^1.19.5": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" + integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + "@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" @@ -2993,7 +3008,7 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" -raw-body@2.5.2: +raw-body@2.5.2, raw-body@^2.5.2: version "2.5.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== From a9368981c08b8801bd832c33ba89b205d42a4727 Mon Sep 17 00:00:00 2001 From: Vladyslav Tkachenko Date: Fri, 15 Dec 2023 14:54:12 +0200 Subject: [PATCH 2/4] docs: update docs --- docs/API.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/API.md b/docs/API.md index a747f9c..c0a582c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -11,29 +11,29 @@ Environment variable: `WHOAMI_API_PATH`, example: `/_/api/whoami` `GET` request to the provided path will return the JSON response that will contain the following information: -```json +```yaml { - // flag to determine if user is authenticated or not + # flag to determine if user is authenticated or not "anonymous": false, "claims": { - // all the claims retrieved from Access and ID tokens based on the JWT_AUTH_CLAIM_PATHS configuration + # all the claims retrieved from Access and ID tokens based on the JWT_AUTH_CLAIM_PATHS configuration "auth": { - /* ... */ + # ... }, - // all the claims retrieved from Access and ID tokens based on the JWT_PROXY_CLAIM_PATHS configuration + # all the claims retrieved from Access and ID tokens based on the JWT_PROXY_CLAIM_PATHS configuration "proxy": { - /* ... */ + # ... }, }, - // optional field, returns the meta information set by the webhook API upon the login action + # optional field, returns the meta information set by the webhook API upon the login action "meta": {} } ``` When user is not authenticated API response will look like the following: -```json +```yaml { "anonymous": true, "claims": { @@ -51,12 +51,12 @@ Environment variable: `PERMISSIONS_API_PATH`, example: `/_/api/whoami` `POST` request with an array of interested resources is expected in the body: -```json +```yaml [ { - // resource path + # resource path "path": "/a/b/c", - // resource method, GET, PUT, POST, PATCH, DELETE + # resource method, GET, PUT, POST, PATCH, DELETE "method": "GET" } ] @@ -64,18 +64,18 @@ Environment variable: `PERMISSIONS_API_PATH`, example: `/_/api/whoami` Response: -```json +```yaml { - // flag to determine if user is authenticated or not + # flag to determine if user is authenticated or not "anonymous": true, - // list of the resources included in the request + # list of the resources included in the request "resources": [ { - // access allowance flag + # access allowance flag "allowed": true, - // resource path + # resource path "path": "/a/b/c", - // resource method + # resource method "method": "GET" } ] From 80bdd12d1fd1dd9b09491bfa9a8da3fd2c53a9b4 Mon Sep 17 00:00:00 2001 From: Vladyslav Tkachenko Date: Fri, 15 Dec 2023 14:57:12 +0200 Subject: [PATCH 3/4] fix: test exit code --- bin/1-test-keycloak.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/1-test-keycloak.sh b/bin/1-test-keycloak.sh index 5ce5d85..e115a54 100644 --- a/bin/1-test-keycloak.sh +++ b/bin/1-test-keycloak.sh @@ -1,4 +1,5 @@ #!/bin/sh +set -e SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" From 572e81812d3e8f0ca215acbde277b877ba17dde3 Mon Sep 17 00:00:00 2001 From: Vladyslav Tkachenko Date: Fri, 15 Dec 2023 15:09:33 +0200 Subject: [PATCH 4/4] fix: test exit code --- .github/workflows/docker-ci.yml | 2 +- bin/0-init-keycloak.sh | 1 + package.json | 3 --- yarn.lock | 29 ----------------------------- 4 files changed, 2 insertions(+), 33 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 9857e56..f02355a 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -25,7 +25,7 @@ jobs: run: yarn - name: Run tests - run: yarn test:keycloak + run: script -e -c "yarn test:keycloak" docker_build_test: runs-on: ubuntu-latest diff --git a/bin/0-init-keycloak.sh b/bin/0-init-keycloak.sh index 7481a7a..badbc8d 100644 --- a/bin/0-init-keycloak.sh +++ b/bin/0-init-keycloak.sh @@ -1,4 +1,5 @@ #!/bin/sh +set -e ############# # VARIABLES # diff --git a/package.json b/package.json index cdffeb7..071addb 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ }, "dependencies": { "@vrbo/pino-rotating-file": "^4.4.0", - "body-parser": "^1.20.2", "cookie": "^0.6.0", "dotenv": "^16.3.1", "jsonwebtoken": "^9.0.1", @@ -52,13 +51,11 @@ }, "devDependencies": { "@testdeck/mocha": "^0.3.3", - "@types/body-parser": "^1.19.5", "@types/cookie": "^0.5.4", "@types/jsonwebtoken": "^9.0.2", "@types/jwk-to-pem": "^2.0.1", "@types/mocha": "^10.0.1", "@types/node": "^20.3.1", - "axios": "^1.4.0", "dev-echo-server": "^0.2.1", "mocha": "^10.2.0", "mochawesome": "^7.1.3", diff --git a/yarn.lock b/yarn.lock index d005ec7..0356c91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -329,21 +329,6 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== -"@types/body-parser@^1.19.5": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" - integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== - dependencies: - "@types/connect" "*" - "@types/node" "*" - -"@types/connect@*": - version "3.4.38" - resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" - integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== - dependencies: - "@types/node" "*" - "@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" @@ -576,15 +561,6 @@ atomic-sleep@^1.0.0: resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== -axios@^1.4.0: - version "1.6.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.2.tgz#de67d42c755b571d3e698df1b6504cde9b0ee9f2" - integrity sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A== - dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - b4a@^1.6.4: version "1.6.4" resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.4.tgz#ef1c1422cae5ce6535ec191baeed7567443f36c9" @@ -1449,11 +1425,6 @@ flat@^5.0.2: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== -follow-redirects@^1.15.0: - version "1.15.3" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" - integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== - for-in@^0.1.3: version "0.1.8" resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1"