Skip to content

Commit

Permalink
Merge pull request #27 from FireBlinkLTD/feat/permissions_api
Browse files Browse the repository at this point in the history
feat: add permissions api endpoint
  • Loading branch information
vlad-tkachenko committed Dec 15, 2023
2 parents 7047cc2 + 572e818 commit 9989454
Show file tree
Hide file tree
Showing 28 changed files with 1,019 additions and 172 deletions.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docker-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions bin/0-init-keycloak.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/bin/sh
set -e

#############
# VARIABLES #
Expand Down
1 change: 1 addition & 0 deletions bin/1-test-keycloak.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/bin/sh
set -e

SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"

Expand Down
55 changes: 47 additions & 8 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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:

```yaml
[
{
# resource path
"path": "/a/b/c",
# resource method, GET, PUT, POST, PATCH, DELETE
"method": "GET"
}
]
```

Response:

```yaml
{
# 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"
}
]
}
```
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"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",
Expand All @@ -55,7 +56,6 @@
"@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",
Expand Down
12 changes: 11 additions & 1 deletion src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,9 +62,15 @@ export const start = async (): Promise<Prxi> => {

// Before request hook
const beforeRequest = (mode: string, method: string, path: string, headers: IncomingHttpHeaders, context: Record<string, any>) => {
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');
}

Expand Down Expand Up @@ -165,6 +173,7 @@ export const start = async (): Promise<Prxi> => {
new LoginHandler(),
new LogoutHandler(),
new WhoamiAPIHandler(),
new PermissionsAPIHandler(),
CallbackHandler,
new ProxyHandler(),
E404Handler,
Expand All @@ -174,6 +183,7 @@ export const start = async (): Promise<Prxi> => {
new Http2LoginHandler(),
new Http2LogoutHandler(),
new Http2WhoamiAPIHandler(),
new Http2PermissionsAPIHandler(),
Http2CallbackHandler,
new Http2ProxyHandler(),
Http2E404Handler,
Expand Down
1 change: 1 addition & 0 deletions src/config/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface Config {
login: string;
api: {
whoami?: string;
permissions?: string;
}
}

Expand Down
1 change: 1 addition & 0 deletions src/config/getConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
},

Expand Down
18 changes: 9 additions & 9 deletions src/handlers/http/ProxyHandler.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down
62 changes: 62 additions & 0 deletions src/handlers/http/api/BaseAccessHandler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, any> = null;
const metaToken = cookies[getConfig().cookies.names.meta];
if (metaToken) {
metaPayload = <JwtPayload> 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<void>;
}
116 changes: 116 additions & 0 deletions src/handlers/http/api/PermissionsAPIHandler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
Loading

0 comments on commit 9989454

Please sign in to comment.