From 32785ea26bfc8ad33f25f0bceffd5bd2880fce27 Mon Sep 17 00:00:00 2001 From: Peter Somogyvari Date: Mon, 3 May 2021 09:20:49 -0700 Subject: [PATCH] feat(cmd-api-server): add Socket.IO as transport #297 Primary changes: --------------- 1. The API server now has a SocketIO server running on the same port as the HTTP REST API. 2. The API server now has an ApiServerApiClient class which is an extension of the DefaultApi class that we generate from the OpenAPI specifications. The reason why this extension was necessary (something that we try to avoid like the plague normally) is because OpenAPI is strictly for defining HTTP/REST based APIs and not async/streaming ones such as WebSocket/SocketIO based ones and therefore the OpenAPI generator does not support these types of transports at all meaning that we have to manually write the client code for async endpoints which is why the class extension here is not something that we can get around. 3. The idea is that all async endpoints would declare their event names as constants in the OpenAPI specification so as to make it easier at least to some degree to implement solutions on top even if we cannot support async endpoints with the OpenAPI code generator to the same extend we do for HTTP RESTful endpoints. The five default events are Subscribe, Next, Unsubscribe, Error and Complete. These are defined in order to match what the client can do on the RxJS Observable object returned by the API client object's method that invokes the async endpoints. The difference between Unsubscribe and complete is subtle, but it definitely exists. The unsubscribe event is used by the client to tell the backend that it no longer requires updates, regardless of the streaming of data having been completed or not. The complete event on the other hand is for the backend to signal that the streaming of data is in fact completed. The complete event is only applicable for endpoints that do have an ending which is not the case for some endpoints that are usually time-series related and therefore a lot of times just stream endlessly until stopped. Secondary change(s): -------------------- 1. Added an async endpoint powered by the just now added SocketIO integration that streams the health check response every one second which is mainly added to that we can test the functionality but at could also be used for monitoring purposes by someone who'd rather implement something from scratch than use Prometheus for example. To-do: ------ 1. Socket provider singleton on client side for connection multiplexing Fixes #297 Signed-off-by: Peter Somogyvari --- .../admin-enroll-v1-endpoint.test.ts | 12 +- .../cactus-cmd-api-server/package-lock.json | 243 +++++++++++++++++- packages/cactus-cmd-api-server/package.json | 4 + .../src/main/json/openapi.json | 17 ++ .../api-client/api-server-api-client.ts | 140 ++++++++++ .../src/main/typescript/api-server.ts | 63 ++++- .../typescript/authzn/authorizer-factory.ts | 15 +- .../authzn/i-authorization-config.ts | 7 +- .../main/typescript/config/config-service.ts | 12 +- .../generated/openapi/typescript-axios/api.ts | 13 + .../src/main/typescript/public-api.ts | 4 + .../watch-healthcheck-v1-endpoint.ts | 70 +++++ .../jwt-endpoint-authorization.test.ts | 13 +- ...t-endpoint-authz-scope-enforcement.test.ts | 11 +- ...wt-socketio-endpoint-authorization.test.ts | 188 ++++++++++++++ ...otected-endpoint-authz-ops-confirm.test.ts | 7 +- .../jwt-unprotected-endpoint-authz.test.ts | 7 +- 17 files changed, 793 insertions(+), 33 deletions(-) create mode 100644 packages/cactus-cmd-api-server/src/main/typescript/api-client/api-server-api-client.ts create mode 100644 packages/cactus-cmd-api-server/src/main/typescript/web-services/watch-healthcheck-v1-endpoint.ts create mode 100644 packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-socketio-endpoint-authorization.test.ts diff --git a/examples/cactus-example-carbon-accounting-backend/src/test/typescript/integration/admin-enroll-v1-endpoint.test.ts b/examples/cactus-example-carbon-accounting-backend/src/test/typescript/integration/admin-enroll-v1-endpoint.test.ts index 96771c981ad..a7a8cc4b5cc 100644 --- a/examples/cactus-example-carbon-accounting-backend/src/test/typescript/integration/admin-enroll-v1-endpoint.test.ts +++ b/examples/cactus-example-carbon-accounting-backend/src/test/typescript/integration/admin-enroll-v1-endpoint.test.ts @@ -46,13 +46,14 @@ test("BEFORE " + testCase, async (t: Test) => { test(testCase, async (t: Test) => { const jwtKeyPair = await JWK.generate("RSA", 4096); const jwtPublicKey = jwtKeyPair.toPEM(false); - const middlewareOptions: expressJwt.Options = { + const expressJwtOptions: expressJwt.Options = { algorithms: ["RS256"], secret: jwtPublicKey, audience: "carbon-accounting-tool-servers-hostname-here", issuer: uuidv4(), }; - t.ok(middlewareOptions, "Express JWT config truthy OK"); + t.ok(expressJwtOptions, "Express JWT config truthy OK"); + const socketIoJwtOptions = { secret: jwtPublicKey }; const httpGui = await Servers.startOnPreferredPort(3000); t.true(httpGui.listening, `httpGui.listening === true`); @@ -67,7 +68,8 @@ test(testCase, async (t: Test) => { const authorizationConfig: IAuthorizationConfig = { unprotectedEndpointExemptions: [], - middlewareOptions, + expressJwtOptions, + socketIoJwtOptions, }; const configService = new ConfigService(); @@ -109,8 +111,8 @@ test(testCase, async (t: Test) => { }; const jwtSignOptions: JWT.SignOptions = { algorithm: "RS256", - issuer: middlewareOptions.issuer, - audience: middlewareOptions.audience, + issuer: expressJwtOptions.issuer, + audience: expressJwtOptions.audience, }; const tokenWithScope = JWT.sign(jwtPayload, jwtKeyPair, jwtSignOptions); const verification = JWT.verify(tokenWithScope, jwtKeyPair, jwtSignOptions); diff --git a/packages/cactus-cmd-api-server/package-lock.json b/packages/cactus-cmd-api-server/package-lock.json index 88530b521e2..d8c53161624 100644 --- a/packages/cactus-cmd-api-server/package-lock.json +++ b/packages/cactus-cmd-api-server/package-lock.json @@ -24,6 +24,14 @@ "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" }, + "@thream/socketio-jwt": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@thream/socketio-jwt/-/socketio-jwt-2.1.0.tgz", + "integrity": "sha512-sBXgVh1VbT9RcjXtYZCbFZXUZVWzUVHcXY5f/AIQqJ0DwMvTAhPmh9IuNs4JufvaUpCYinbbke+IOS5hwTJ0nA==", + "requires": { + "jsonwebtoken": "8.5.1" + } + }, "@types/body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", @@ -34,6 +42,11 @@ "@types/node": "*" } }, + "@types/component-emitter": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz", + "integrity": "sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg==" + }, "@types/compression": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.0.tgz", @@ -61,6 +74,11 @@ "@types/node": "*" } }, + "@types/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg==" + }, "@types/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.6.tgz", @@ -121,6 +139,15 @@ "@types/express": "*" } }, + "@types/jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-rNAPdomlIUX0i0cg2+I+Q1wOUr531zHBQ+cV/28PJ39bSPKjahatZZ2LMuhiguETkCgLVzfruw/ZvNMNkKoSzw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/mime": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", @@ -139,8 +166,7 @@ "@types/node": { "version": "13.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-13.9.1.tgz", - "integrity": "sha512-E6M6N0blf/jiZx8Q3nb0vNaswQeEyn0XlupO+xN6DtJ6r6IT4nXrTry7zhIfYvFCl3/8Cu6WIysmUBKiqV0bqQ==", - "dev": true + "integrity": "sha512-E6M6N0blf/jiZx8Q3nb0vNaswQeEyn0XlupO+xN6DtJ6r6IT4nXrTry7zhIfYvFCl3/8Cu6WIysmUBKiqV0bqQ==" }, "@types/node-forge": { "version": "0.9.3", @@ -306,6 +332,21 @@ "follow-redirects": "^1.10.0" } }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" + }, + "base64-arraybuffer": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz", + "integrity": "sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=" + }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" + }, "bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -371,6 +412,11 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" + }, "compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -554,6 +600,79 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, + "engine.io": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-5.0.0.tgz", + "integrity": "sha512-BATIdDV3H1SrE9/u2BAotvsmjJg0t1P4+vGedImSs1lkFAtQdvk4Ev1y4LDiPF7BPWgXWEG+NDY+nLvW3UrMWw==", + "requires": { + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~4.0.0", + "ws": "~7.4.2" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "engine.io-client": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-5.0.1.tgz", + "integrity": "sha512-CQtGN3YwfvbxVwpPugcsHe5rHT4KgT49CEcQppNtu9N7WxbPN0MAG27lGaem7bvtCFtGNLSL+GEqXsFSz36jTg==", + "requires": { + "base64-arraybuffer": "0.1.4", + "component-emitter": "~1.3.0", + "debug": "~4.3.1", + "engine.io-parser": "~4.0.1", + "has-cors": "1.1.0", + "parseqs": "0.0.6", + "parseuri": "0.0.6", + "ws": "~7.4.2", + "yeast": "0.1.2" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "engine.io-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-4.0.2.tgz", + "integrity": "sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg==", + "requires": { + "base64-arraybuffer": "0.1.4" + } + }, "es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", @@ -733,6 +852,11 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, + "has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=" + }, "http-errors": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", @@ -2784,6 +2908,16 @@ "@jsdevtools/ono": "7.1.1" } }, + "parseqs": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz", + "integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==" + }, + "parseuri": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz", + "integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==" + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2911,6 +3045,101 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" }, + "socket.io": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.0.1.tgz", + "integrity": "sha512-g8eZB9lV0f4X4gndG0k7YZAywOg1VxYgCUspS4V+sDqsgI/duqd0AW84pKkbGj/wQwxrqrEq+VZrspRfTbHTAQ==", + "requires": { + "@types/cookie": "^0.4.0", + "@types/cors": "^2.8.8", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "debug": "~4.3.1", + "engine.io": "~5.0.0", + "socket.io-adapter": "~2.2.0", + "socket.io-parser": "~4.0.3" + }, + "dependencies": { + "@types/cors": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.10.tgz", + "integrity": "sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ==" + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "socket.io-adapter": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.2.0.tgz", + "integrity": "sha512-rG49L+FwaVEwuAdeBRq49M97YI3ElVabJPzvHT9S6a2CWhDKnjSFasvwAwSYPRhQzfn4NtDIbCaGYgOCOU/rlg==" + }, + "socket.io-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.0.1.tgz", + "integrity": "sha512-6AkaEG5zrVuSVW294cH1chioag9i1OqnCYjKwTc3EBGXbnyb98Lw7yMa40ifLjFj3y6fsFKsd0llbUZUCRf3Qw==", + "requires": { + "@types/component-emitter": "^1.2.10", + "backo2": "~1.0.2", + "component-emitter": "~1.3.0", + "debug": "~4.3.1", + "engine.io-client": "~5.0.0", + "parseuri": "0.0.6", + "socket.io-parser": "~4.0.4" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "socket.io-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.4.tgz", + "integrity": "sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==", + "requires": { + "@types/component-emitter": "^1.2.10", + "component-emitter": "~1.3.0", + "debug": "~4.3.1" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -3001,6 +3230,11 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, + "ws": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz", + "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -3014,6 +3248,11 @@ "camelcase": "^5.0.0", "decamelize": "^1.2.0" } + }, + "yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=" } } } diff --git a/packages/cactus-cmd-api-server/package.json b/packages/cactus-cmd-api-server/package.json index 638bd1ab51c..b222cf4af04 100644 --- a/packages/cactus-cmd-api-server/package.json +++ b/packages/cactus-cmd-api-server/package.json @@ -89,6 +89,7 @@ "@hyperledger/cactus-common": "0.5.0", "@hyperledger/cactus-core": "0.5.0", "@hyperledger/cactus-core-api": "0.5.0", + "@thream/socketio-jwt": "2.1.0", "axios": "0.21.1", "body-parser": "1.19.0", "compression": "1.7.4", @@ -106,6 +107,8 @@ "npm": "7.8.0", "prom-client": "13.1.0", "semver": "7.3.2", + "socket.io": "4.0.1", + "socket.io-client": "4.0.1", "typescript-optional": "2.0.1", "uuid": "7.0.2" }, @@ -119,6 +122,7 @@ "@types/express": "4.17.8", "@types/express-http-proxy": "1.6.1", "@types/express-jwt": "6.0.1", + "@types/jsonwebtoken": "8.5.1", "@types/multer": "1.4.5", "@types/node-forge": "0.9.3", "@types/npm": "2.0.31", diff --git a/packages/cactus-cmd-api-server/src/main/json/openapi.json b/packages/cactus-cmd-api-server/src/main/json/openapi.json index c9b40dc2a04..092c63ba97b 100644 --- a/packages/cactus-cmd-api-server/src/main/json/openapi.json +++ b/packages/cactus-cmd-api-server/src/main/json/openapi.json @@ -31,6 +31,23 @@ ], "components": { "schemas": { + "WatchHealthcheckV1": { + "type": "string", + "enum": [ + "org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Subscribe", + "org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Next", + "org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Unsubscribe", + "org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Error", + "org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Complete" + ], + "x-enum-varnames": [ + "Subscribe", + "Next", + "Unsubscribe", + "Error", + "Complete" + ] + }, "MemoryUsage": { "type": "object", "properties": { diff --git a/packages/cactus-cmd-api-server/src/main/typescript/api-client/api-server-api-client.ts b/packages/cactus-cmd-api-server/src/main/typescript/api-client/api-server-api-client.ts new file mode 100644 index 00000000000..40671440673 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/typescript/api-client/api-server-api-client.ts @@ -0,0 +1,140 @@ +import { Observable, ReplaySubject } from "rxjs"; +import { finalize } from "rxjs/operators"; +import { Socket, io, SocketOptions } from "socket.io-client"; +import { Logger, Checks, IAsyncProvider } from "@hyperledger/cactus-common"; +import { LogLevelDesc, LoggerProvider } from "@hyperledger/cactus-common"; +import { Constants } from "@hyperledger/cactus-core-api"; +import { + DefaultApi, + WatchHealthcheckV1, + HealthCheckResponse, +} from "../generated/openapi/typescript-axios"; +import { Configuration } from "../generated/openapi/typescript-axios/configuration"; +import { Optional } from "typescript-optional"; + +export interface IApiServerApiClientOptions extends Configuration { + readonly logLevel?: LogLevelDesc; + readonly wsApiHost?: string; + readonly wsApiPath?: string; + readonly tokenProvider?: IAsyncProvider; +} + +export class ApiServerApiClientConfiguration extends Configuration { + public static readonly CLASS_NAME = "ApiServerApiClientConfiguration"; + + public readonly logLevel: LogLevelDesc; + public readonly wsApiHost: string; + public readonly wsApiPath: string; + public readonly tokenProvider: Optional>; + + public get className(): string { + return ApiServerApiClient.CLASS_NAME; + } + + constructor(public readonly options: IApiServerApiClientOptions) { + super(options); + const fnTag = `${this.className}#constructor()`; + this.logLevel = options.logLevel || "INFO"; + this.wsApiHost = options.wsApiHost || options.basePath || location.host; + this.wsApiPath = options.wsApiPath || Constants.SocketIoConnectionPathV1; + this.tokenProvider = Optional.ofNullable(options.tokenProvider); + Checks.nonBlankString(this.logLevel, `${fnTag}:logLevel`); + Checks.nonBlankString(this.wsApiHost, `${fnTag}:wsApiHost`); + Checks.nonBlankString(this.wsApiPath, `${fnTag}:wsApiPath`); + Checks.truthy(this.tokenProvider, `${fnTag}:tokenProvider`); + } +} + +export class ApiServerApiClient extends DefaultApi { + public static readonly CLASS_NAME = "ApiServerApiClient"; + + public readonly log: Logger; + public readonly wsApiHost: string; + public readonly wsApiPath: string; + public readonly tokenProvider: Optional>; + + public get className(): string { + return ApiServerApiClient.CLASS_NAME; + } + + constructor(public readonly options: ApiServerApiClientConfiguration) { + super(options); + const fnTag = `${this.className}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + + const label = this.className; + this.log = LoggerProvider.getOrCreate({ level: options.logLevel, label }); + + this.wsApiHost = options.wsApiHost; + this.wsApiPath = options.wsApiPath; + this.tokenProvider = options.tokenProvider; + this.log.debug(`Created ${this.className} OK.`); + this.log.debug(`wsApiHost=${this.wsApiHost}`); + this.log.debug(`wsApiPath=${this.wsApiPath}`); + this.log.debug(`basePath=${this.options.basePath}`); + } + + public async watchHealthcheckV1(): Promise> { + const { log, tokenProvider } = this; + + const socketOptions: SocketOptions = { + auth: { token: this.configuration?.baseOptions.headers.Authorization }, + path: this.wsApiPath, + } as any; // TODO + + const socket: Socket = io(this.wsApiHost, socketOptions); + const subject = new ReplaySubject(1); + + socket.on("error", (ex: Error) => { + log.error("[SocketIOClient] ERROR: %o", ex); + socket.disconnect(); + subject.error(ex); + }); + + socket.on("connect_error", async (err) => { + log.debug("[SocketIOClient] CONNECT_ERROR: %o", err); + if (tokenProvider.isPresent()) { + const theProvider = tokenProvider.get(); // unwrap the Optional + const token = await theProvider.get(); // get the actual token + socket.auth = { token }; + this.options.baseOptions = { headers: { Authorization: token } }; + log.debug("Received fresh token from token provider OK"); + } else { + socket.disconnect(); + subject.error(err); + log.debug("Disconnected socket, send error to RxJS subject."); + } + }); + + socket.on(WatchHealthcheckV1.Next, (data: HealthCheckResponse) => { + subject.next(data); + }); + + socket.on("connect", () => { + const transport = socket.io.engine.transport.name; // in most cases, "polling" + + socket.io.engine.on("upgrade", () => { + const upgradedTransport = socket.io.engine.transport.name; // in most cases, "websocket" + log.debug("[SocketIOClient] Upgraded transport=%o", upgradedTransport); + }); + log.debug("[SocketIOClient] Connected OK"); + log.debug("[SocketIOClient] initial transport=%o", transport); + socket.emit(WatchHealthcheckV1.Subscribe); + }); + + return subject.pipe( + finalize(() => { + log.debug("Emitting unsubscribe, disconnecting socket..."); + socket.emit(WatchHealthcheckV1.Unsubscribe); + socket.disconnect(); + log.debug("Completing RxJS subject..."); + subject.complete(); + log.debug("Finalized RxJS subject OK"); + }), + // TODO: Investigate if we need these below - in theory without these + // it could happen that only the fist subscriber gets the last emitted value + // publishReplay(1), + // refCount(), + ); + } +} diff --git a/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts b/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts index 02b42fbf292..138e538dae3 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts @@ -14,6 +14,11 @@ import compression from "compression"; import bodyParser from "body-parser"; import cors from "cors"; +import { Server as SocketIoServer } from "socket.io"; +import type { ServerOptions as SocketIoServerOptions } from "socket.io"; +import type { Socket as SocketIoSocket } from "socket.io"; +import { authorize as authorizeSocket } from "@thream/socketio-jwt"; + import { ICactusPlugin, isIPluginWebService, @@ -21,6 +26,7 @@ import { IPluginFactoryOptions, PluginFactoryFactory, PluginImport, + Constants, } from "@hyperledger/cactus-core-api"; import { PluginRegistry } from "@hyperledger/cactus-core"; @@ -33,9 +39,13 @@ import { OpenAPIV3 } from "express-openapi-validator/dist/framework/types"; import { PrometheusExporter } from "./prometheus-exporter/prometheus-exporter"; import { AuthorizerFactory } from "./authzn/authorizer-factory"; +import { WatchHealthcheckV1 } from "./generated/openapi/typescript-axios"; +import { WatchHealthcheckV1Endpoint } from "./web-services/watch-healthcheck-v1-endpoint"; export interface IApiServerConstructorOptions { pluginRegistry?: PluginRegistry; httpServerApi?: Server | SecureServer; + wsServerApi?: SocketIoServer; + wsOptions?: SocketIoServerOptions; httpServerCockpit?: Server | SecureServer; config: ICactusApiServerOptions; prometheusExporter?: PrometheusExporter; @@ -58,6 +68,9 @@ export class ApiServer { private pluginRegistry: PluginRegistry | undefined; private readonly httpServerApi: Server | SecureServer; private readonly httpServerCockpit: Server | SecureServer; + private readonly wsApi: SocketIoServer; + private readonly expressApi: Application; + private readonly expressCockpit: Application; public prometheusExporter: PrometheusExporter; public get className(): string { @@ -96,6 +109,10 @@ export class ApiServer { this.httpServerCockpit = createServer(); } + this.wsApi = new SocketIoServer(); + this.expressApi = express(); + this.expressCockpit = express(); + if (this.options.prometheusExporter) { this.prometheusExporter = this.options.prometheusExporter; } else { @@ -334,6 +351,7 @@ export class ApiServer { } async startCockpitFileServer(): Promise { + const { expressCockpit: app } = this; const cockpitWwwRoot = this.options.config.cockpitWwwRoot; this.log.info(`wwwRoot: ${cockpitWwwRoot}`); @@ -372,7 +390,6 @@ export class ApiServer { }, }); - const app: Application = express(); app.use("/api/v*", apiProxyMiddleware); app.use(compression()); app.use(corsMiddleware); @@ -406,6 +423,9 @@ export class ApiServer { * @param app */ async getOrCreateWebServices(app: express.Application): Promise { + const { log } = this; + const { logLevel } = this.options.config; + const healthcheckHandler = (req: Request, res: Response) => { res.json({ success: true, @@ -419,6 +439,25 @@ export class ApiServer { const { path: httpPath, verbLowerCase: httpVerb } = http; (app as any)[httpVerb](httpPath, healthcheckHandler); + this.wsApi.on("connection", (socket: SocketIoSocket) => { + const { id } = socket; + const transport = socket.conn.transport.name; // in most cases, "polling" + log.debug(`Socket connected. ID=${id} transport=%o`, transport); + + socket.conn.on("upgrade", () => { + const upgradedTransport = socket.conn.transport.name; // in most cases, "websocket" + log.debug(`Socket upgraded ID=${id} transport=%o`, upgradedTransport); + }); + + socket.on(WatchHealthcheckV1.Subscribe, () => + new WatchHealthcheckV1Endpoint({ + process, + socket, + logLevel, + }).subscribe(), + ); + }); + const prometheusExporterHandler = (req: Request, res: Response) => { this.getPrometheusExporterMetrics().then((resBody) => { res.status(200); @@ -443,7 +482,7 @@ export class ApiServer { } async startApiServer(): Promise { - const { options } = this; + const { options, expressApi: app, wsApi } = this; const { config } = options; const { authorizationConfigJson: authzConf, @@ -454,7 +493,6 @@ export class ApiServer { const pluginRegistry = await this.getOrInitPluginRegistry(); - const app: Application = express(); app.use(compression()); const apiCorsDomainCsv = this.options.config.apiCorsDomainCsv; @@ -490,7 +528,7 @@ export class ApiServer { .map(async (plugin: ICactusPlugin) => { const p = plugin as IPluginWebService; await p.getOrCreateWebServices(); - const webSvcs = await p.registerWebServices(app, null as any); + const webSvcs = await p.registerWebServices(app, wsApi); return webSvcs; }); @@ -518,6 +556,23 @@ export class ApiServer { const addressInfo = this.httpServerApi.address() as AddressInfo; this.log.info(`Cactus API net.AddressInfo`, addressInfo); + const wsOptions = { + path: Constants.SocketIoConnectionPathV1, + serveClient: false, + ...this.options.wsOptions, + } as SocketIoServerOptions; + + this.wsApi.attach(this.httpServerApi, wsOptions); + + const socketIoAuthorizer = authorizeSocket({ + ...authzConf.socketIoJwtOptions, + onAuthentication: (decodedToken) => { + this.log.debug("Socket authorized OK: %o", decodedToken); + }, + }); + + this.wsApi.use(socketIoAuthorizer); + return addressInfo; } diff --git a/packages/cactus-cmd-api-server/src/main/typescript/authzn/authorizer-factory.ts b/packages/cactus-cmd-api-server/src/main/typescript/authzn/authorizer-factory.ts index e9e98d04ebf..5731b62368e 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/authzn/authorizer-factory.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/authzn/authorizer-factory.ts @@ -76,20 +76,29 @@ export class AuthorizerFactory { authzConf: IAuthorizationConfig, ): Promise { const fnTag = `${this.className}#createExpressJwtMiddleware()`; + const { log } = this; const { E_BAD_EXPRESS_JWT_OPTIONS } = AuthorizerFactory; - const { middlewareOptions } = authzConf; - if (!isExpressJwtOptions(middlewareOptions)) { + const { expressJwtOptions, socketIoPath } = authzConf; + if (!isExpressJwtOptions(expressJwtOptions)) { throw new Error(`${fnTag}: ${E_BAD_EXPRESS_JWT_OPTIONS}`); } const options: expressJwt.Options = { audience: "org.hyperledger.cactus", // default that can be overridden - ...middlewareOptions, + ...expressJwtOptions, }; const unprotectedEndpoints = this.unprotectedEndpoints.map((e) => { // type pathFilter = string | RegExp | { url: string | RegExp, methods?: string[], method?: string | string[] }; return { url: e.getPath(), method: e.getVerbLowerCase() }; }); + if (socketIoPath) { + log.info("SocketIO path configuration detected: %o", socketIoPath); + // const exemption = { url: new RegExp(`${socketIoPath}.*`), method: "get" }; + // unprotectedEndpoints.push(exemption as any); + log.info( + "Exempted SocketIO path from express-jwt authorization. Using @thream/socketio-jwt instead)", + ); + } return expressJwt(options).unless({ path: unprotectedEndpoints }); } diff --git a/packages/cactus-cmd-api-server/src/main/typescript/authzn/i-authorization-config.ts b/packages/cactus-cmd-api-server/src/main/typescript/authzn/i-authorization-config.ts index 65e3e1e6636..ed6ca101fc9 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/authzn/i-authorization-config.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/authzn/i-authorization-config.ts @@ -1,4 +1,9 @@ +import type { Options as ExpressJwtOptions } from "express-jwt"; +import type { AuthorizeOptions as SocketIoJwtOptions } from "@thream/socketio-jwt"; + export interface IAuthorizationConfig { - middlewareOptions: Record; + expressJwtOptions: ExpressJwtOptions; + socketIoJwtOptions: SocketIoJwtOptions; unprotectedEndpointExemptions: Array; + socketIoPath?: string; } diff --git a/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts b/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts index 3a18e653acb..5fa49f47713 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts @@ -12,6 +12,7 @@ import { } from "@hyperledger/cactus-common"; import { ConsortiumDatabase, + Constants, PluginImport, PluginImportType, } from "@hyperledger/cactus-core-api"; @@ -510,12 +511,19 @@ export class ConfigService { }, ]; + const jwtSecret = uuidV4(); + return { authorizationProtocol: AuthorizationProtocol.JSON_WEB_TOKEN, authorizationConfigJson: { + socketIoPath: Constants.SocketIoConnectionPathV1, unprotectedEndpointExemptions: [], - middlewareOptions: { - secret: uuidV4(), + socketIoJwtOptions: { + secret: jwtSecret, + }, + expressJwtOptions: { + secret: jwtSecret, + algorithms: ["RS256"], audience: "org.hyperledger.cactus.jwt.audience", issuer: "org.hyperledger.cactus.jwt.issuer", }, diff --git a/packages/cactus-cmd-api-server/src/main/typescript/generated/openapi/typescript-axios/api.ts b/packages/cactus-cmd-api-server/src/main/typescript/generated/openapi/typescript-axios/api.ts index 682251644e8..1f6414ed049 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/generated/openapi/typescript-axios/api.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/generated/openapi/typescript-axios/api.ts @@ -81,6 +81,19 @@ export interface MemoryUsage { */ arrayBuffers?: number; } +/** + * + * @export + * @enum {string} + */ +export enum WatchHealthcheckV1 { + Subscribe = 'org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Subscribe', + Next = 'org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Next', + Unsubscribe = 'org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Unsubscribe', + Error = 'org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Error', + Complete = 'org.hyperledger.cactus.api.async.besu.WatchHealthcheckV1.Complete' +} + /** * DefaultApi - axios parameter creator diff --git a/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts b/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts index 77f01985d1d..f2fdad000b1 100755 --- a/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts @@ -18,6 +18,10 @@ export { export * from "./generated/openapi/typescript-axios/index"; +export { ApiServerApiClient } from "./api-client/api-server-api-client"; +export { ApiServerApiClientConfiguration } from "./api-client/api-server-api-client"; +export { IApiServerApiClientOptions } from "./api-client/api-server-api-client"; + export { isHealthcheckResponse } from "./model/is-healthcheck-response-type-guard"; export { isExpressJwtOptions } from "./authzn/is-express-jwt-options-type-guard"; diff --git a/packages/cactus-cmd-api-server/src/main/typescript/web-services/watch-healthcheck-v1-endpoint.ts b/packages/cactus-cmd-api-server/src/main/typescript/web-services/watch-healthcheck-v1-endpoint.ts new file mode 100644 index 00000000000..c6db12eea01 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/typescript/web-services/watch-healthcheck-v1-endpoint.ts @@ -0,0 +1,70 @@ +import { Socket as SocketIoSocket } from "socket.io"; + +import { Logger, Checks } from "@hyperledger/cactus-common"; +import { LogLevelDesc, LoggerProvider } from "@hyperledger/cactus-common"; +import { HealthCheckResponse } from "../generated/openapi/typescript-axios"; +import { WatchHealthcheckV1 } from "../generated/openapi/typescript-axios"; + +export interface IWatchHealthcheckV1EndpointOptions { + logLevel?: LogLevelDesc; + socket: SocketIoSocket; + process: NodeJS.Process; +} + +export class WatchHealthcheckV1Endpoint { + public static readonly CLASS_NAME = "WatchHealthcheckV1Endpoint"; + + private readonly log: Logger; + private readonly socket: SocketIoSocket< + Record void>, + Record void> + >; + private readonly process: NodeJS.Process; + + public get className(): string { + return WatchHealthcheckV1Endpoint.CLASS_NAME; + } + + constructor(public readonly options: IWatchHealthcheckV1EndpointOptions) { + const fnTag = `${this.className}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + Checks.truthy(options.process, `${fnTag} arg options.process`); + Checks.truthy(options.socket, `${fnTag} arg options.socket`); + + this.process = options.process; + this.socket = options.socket; + + const level = this.options.logLevel || "INFO"; + const label = this.className; + this.log = LoggerProvider.getOrCreate({ level, label }); + } + + public async subscribe(): Promise { + const { socket, log } = this; + log.debug(`${WatchHealthcheckV1.Subscribe} => ${socket.id}`); + + const timerId = setInterval(() => { + try { + const next: HealthCheckResponse = { + createdAt: new Date().toJSON(), + memoryUsage: this.process.memoryUsage(), + success: true, + }; + socket.emit(WatchHealthcheckV1.Next, next); + } catch (ex) { + log.error(`Failed to construct health check response:`, ex); + socket.emit(WatchHealthcheckV1.Error, ex); + clearInterval(timerId); + } + }, 1000); + + socket.on("disconnect", async (reason: string) => { + log.debug("WebSocket:disconnect reason=%o", reason); + clearInterval(timerId); + }); + + socket.on(WatchHealthcheckV1.Unsubscribe, () => { + clearInterval(timerId); + }); + } +} diff --git a/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-endpoint-authorization.test.ts b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-endpoint-authorization.test.ts index 03e6566a298..5eb65021f2f 100644 --- a/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-endpoint-authorization.test.ts +++ b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-endpoint-authorization.test.ts @@ -38,26 +38,29 @@ test(testCase, async (t: Test) => { const jwtKeyPair = await JWK.generate("RSA", 4096); const jwtPublicKey = jwtKeyPair.toPEM(false); - const middlewareOptions: expressJwt.Options = { + const expressJwtOptions: expressJwt.Options = { algorithms: ["RS256"], secret: jwtPublicKey, audience: uuidv4(), issuer: uuidv4(), }; - t.ok(middlewareOptions, "Express JWT config truthy OK"); + t.ok(expressJwtOptions, "Express JWT config truthy OK"); const jwtPayload = { name: "Peter", location: "London" }; const jwtSignOptions: JWT.SignOptions = { algorithm: "RS256", - issuer: middlewareOptions.issuer, - audience: middlewareOptions.audience, + issuer: expressJwtOptions.issuer, + audience: expressJwtOptions.audience, }; const tokenGood = JWT.sign(jwtPayload, jwtKeyPair, jwtSignOptions); // const tokenBad = JWT.sign(jwtPayload, jwtKeyPair); const authorizationConfig: IAuthorizationConfig = { unprotectedEndpointExemptions: [], - middlewareOptions, + expressJwtOptions, + socketIoJwtOptions: { + secret: jwtPublicKey, + }, }; const configService = new ConfigService(); diff --git a/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-endpoint-authz-scope-enforcement.test.ts b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-endpoint-authz-scope-enforcement.test.ts index fd682996e9d..539e143765e 100644 --- a/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-endpoint-authz-scope-enforcement.test.ts +++ b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-endpoint-authz-scope-enforcement.test.ts @@ -32,13 +32,13 @@ test(testCase, async (t: Test) => { try { const jwtKeyPair = await JWK.generate("RSA", 4096); const jwtPublicKey = jwtKeyPair.toPEM(false); - const middlewareOptions: expressJwt.Options = { + const expressJwtOptions: expressJwt.Options = { algorithms: ["RS256"], secret: jwtPublicKey, audience: uuidv4(), issuer: uuidv4(), }; - t.ok(middlewareOptions, "Express JWT config truthy OK"); + t.ok(expressJwtOptions, "Express JWT config truthy OK"); const unprotectedActionEp = new UnprotectedActionEndpoint({ connector: {} as PluginLedgerConnectorStub, @@ -47,7 +47,8 @@ test(testCase, async (t: Test) => { const authorizationConfig: IAuthorizationConfig = { unprotectedEndpointExemptions: [unprotectedActionEp.getPath()], - middlewareOptions, + expressJwtOptions, + socketIoJwtOptions: { secret: jwtPublicKey }, }; const pluginRegistry = new PluginRegistry(); @@ -95,8 +96,8 @@ test(testCase, async (t: Test) => { }; const jwtSignOptions: JWT.SignOptions = { algorithm: "RS256", - issuer: middlewareOptions.issuer, - audience: middlewareOptions.audience, + issuer: expressJwtOptions.issuer, + audience: expressJwtOptions.audience, }; const token = JWT.sign(jwtPayload, jwtKeyPair, jwtSignOptions); diff --git a/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-socketio-endpoint-authorization.test.ts b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-socketio-endpoint-authorization.test.ts new file mode 100644 index 00000000000..f43c73b987c --- /dev/null +++ b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-socketio-endpoint-authorization.test.ts @@ -0,0 +1,188 @@ +import test, { Test } from "tape-promise/tape"; +import { v4 as uuidv4 } from "uuid"; +import { JWK, JWT } from "jose"; +import type { Options as ExpressJwtOptions } from "express-jwt"; +import type { AuthorizeOptions as SocketIoJwtOptions } from "@thream/socketio-jwt"; + +import { Constants } from "@hyperledger/cactus-core-api"; +import { + ApiServer, + ConfigService, + HealthCheckResponse, + isHealthcheckResponse, +} from "../../../main/typescript/public-api"; +import { ApiServerApiClient } from "../../../main/typescript/public-api"; +import { ApiServerApiClientConfiguration } from "../../../main/typescript/public-api"; +import { LoggerProvider, LogLevelDesc } from "@hyperledger/cactus-common"; +import { AuthorizationProtocol } from "../../../main/typescript/config/authorization-protocol"; +import { IAuthorizationConfig } from "../../../main/typescript/authzn/i-authorization-config"; + +const testCase = "API server enforces authorization for SocketIO endpoints"; +const logLevel: LogLevelDesc = "TRACE"; +const log = LoggerProvider.getOrCreate({ + level: logLevel, + label: __filename, +}); + +test(testCase, async (t: Test) => { + try { + const jwtKeyPair = await JWK.generate("RSA", 4096); + const jwtPublicKey = jwtKeyPair.toPEM(false); + const expressJwtOptions: ExpressJwtOptions = { + algorithms: ["RS256"], + secret: jwtPublicKey, + audience: uuidv4(), + issuer: uuidv4(), + }; + const socketIoJwtOptions: SocketIoJwtOptions = { + secret: jwtPublicKey, + algorithms: ["RS256"], + }; + t.ok(expressJwtOptions, "Express JWT config truthy OK"); + + const authorizationConfig: IAuthorizationConfig = { + unprotectedEndpointExemptions: [], + expressJwtOptions, + socketIoJwtOptions, + socketIoPath: Constants.SocketIoConnectionPathV1, + }; + + const configService = new ConfigService(); + const apiSrvOpts = configService.newExampleConfig(); + apiSrvOpts.authorizationProtocol = AuthorizationProtocol.JSON_WEB_TOKEN; + apiSrvOpts.authorizationConfigJson = authorizationConfig; + apiSrvOpts.configFile = ""; + apiSrvOpts.apiCorsDomainCsv = "*"; + apiSrvOpts.apiPort = 0; + apiSrvOpts.cockpitPort = 0; + apiSrvOpts.apiTlsEnabled = false; + apiSrvOpts.plugins = []; + const config = configService.newExampleConfigConvict(apiSrvOpts); + + const apiServer = new ApiServer({ + config: config.getProperties(), + }); + test.onFinish(async () => await apiServer.shutdown()); + + const startResponse = apiServer.start(); + await t.doesNotReject(startResponse, "API server started OK"); + t.ok(startResponse, "API server start response truthy OK"); + + const addressInfoApi = (await startResponse).addressInfoApi; + const protocol = apiSrvOpts.apiTlsEnabled ? "https" : "http"; + const { address, port } = addressInfoApi; + const apiHost = `${protocol}://${address}:${port}`; + + const jwtPayload = { name: "Peter", location: "Albertirsa" }; + const jwtSignOptions: JWT.SignOptions = { + algorithm: "RS256", + issuer: expressJwtOptions.issuer, + audience: expressJwtOptions.audience, + }; + const validJwt = JWT.sign(jwtPayload, jwtKeyPair, jwtSignOptions); + t.ok(validJwt, "JWT signed truthy OK"); + + const validBearerToken = `Bearer ${validJwt}`; + t.ok(validBearerToken, "validBearerToken truthy OK"); + + const apiClientBad = new ApiServerApiClient( + new ApiServerApiClientConfiguration({ + basePath: apiHost, + baseOptions: { headers: { Authorization: "Mr. Invalid Token" } }, + logLevel: "TRACE", + }), + ); + + const apiClientFixable = new ApiServerApiClient( + new ApiServerApiClientConfiguration({ + basePath: apiHost, + baseOptions: { headers: { Authorization: "Mr. Invalid Token" } }, + logLevel: "TRACE", + tokenProvider: { + get: () => Promise.resolve(validBearerToken), + }, + }), + ); + + const apiClientGood = new ApiServerApiClient( + new ApiServerApiClientConfiguration({ + basePath: apiHost, + baseOptions: { headers: { Authorization: validBearerToken } }, + logLevel: "TRACE", + tokenProvider: { + get: () => Promise.resolve(validBearerToken), + }, + }), + ); + + { + const healthchecks = await apiClientBad.watchHealthcheckV1(); + + const watchHealthcheckV1WithBadToken = new Promise((resolve, reject) => { + healthchecks.subscribe({ + next: () => { + resolve(new Error("Was authorized with an invalid token, bad.")); + }, + error: (ex: Error) => { + reject(ex); + }, + complete: () => { + resolve(new Error("Was authorized with an invalid token, bad.")); + }, + }); + }); + + await t.rejects( + watchHealthcheckV1WithBadToken, + /Format is Authorization: Bearer \[token\]/, + "SocketIO connection rejected when JWT is invalid OK", + ); + + const resHc = await apiClientGood.getHealthCheck(); + t.ok(resHc, "healthcheck response truthy OK"); + t.equal(resHc.status, 200, "healthcheck response status === 200 OK"); + t.equal(typeof resHc.data, "object", "typeof resHc.data is 'object' OK"); + t.ok(resHc.data.createdAt, "resHc.data.createdAt truthy OK"); + t.ok(resHc.data.memoryUsage, "resHc.data.memoryUsage truthy OK"); + t.ok(resHc.data.memoryUsage.rss, "resHc.data.memoryUsage.rss truthy OK"); + t.ok(resHc.data.success, "resHc.data.success truthy OK"); + t.true(isHealthcheckResponse(resHc.data), "isHealthcheckResponse OK"); + } + + { + let idx = 0; + const healthchecks = await apiClientFixable.watchHealthcheckV1(); + const sub = healthchecks.subscribe((next: HealthCheckResponse) => { + idx++; + t.ok(next, idx + " next healthcheck truthy OK"); + t.equal(typeof next, "object", idx + "typeof next is 'object' OK"); + t.ok(next.createdAt, idx + " next.createdAt truthy OK"); + t.ok(next.memoryUsage, idx + " next.memoryUsage truthy OK"); + t.ok(next.memoryUsage.rss, idx + " next.memoryUsage.rss truthy OK"); + t.ok(next.success, idx + " next.success truthy OK"); + t.true(isHealthcheckResponse(next), idx + " isHealthcheckResponse OK"); + if (idx > 2) { + sub.unsubscribe(); + } + }); + + const all = await healthchecks.toPromise(); + t.comment("all=" + JSON.stringify(all)); + + const resHc = await apiClientFixable.getHealthCheck(); + t.ok(resHc, "healthcheck response truthy OK"); + t.equal(resHc.status, 200, "healthcheck response status === 200 OK"); + t.equal(typeof resHc.data, "object", "typeof resHc.data is 'object' OK"); + t.ok(resHc.data.createdAt, "resHc.data.createdAt truthy OK"); + t.ok(resHc.data.memoryUsage, "resHc.data.memoryUsage truthy OK"); + t.ok(resHc.data.memoryUsage.rss, "resHc.data.memoryUsage.rss truthy OK"); + t.ok(resHc.data.success, "resHc.data.success truthy OK"); + t.true(isHealthcheckResponse(resHc.data), "isHealthcheckResponse OK"); + } + t.end(); + } catch (ex) { + log.error(ex); + t.fail("Exception thrown during test execution, see above for details!"); + throw ex; + } +}); diff --git a/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-unprotected-endpoint-authz-ops-confirm.test.ts b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-unprotected-endpoint-authz-ops-confirm.test.ts index a7388d8dfff..1bebde1a90c 100644 --- a/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-unprotected-endpoint-authz-ops-confirm.test.ts +++ b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-unprotected-endpoint-authz-ops-confirm.test.ts @@ -27,17 +27,18 @@ test(testCase, async (t: Test) => { try { const jwtKeyPair = await JWK.generate("RSA", 4096); const jwtPublicKey = jwtKeyPair.toPEM(false); - const middlewareOptions: expressJwt.Options = { + const expressJwtOptions: expressJwt.Options = { algorithms: ["RS256"], secret: jwtPublicKey, audience: uuidv4(), issuer: uuidv4(), }; - t.ok(middlewareOptions, "Express JWT config truthy OK"); + t.ok(expressJwtOptions, "Express JWT config truthy OK"); const authorizationConfig: IAuthorizationConfig = { unprotectedEndpointExemptions: [], - middlewareOptions, + expressJwtOptions, + socketIoJwtOptions: { secret: jwtPublicKey }, }; const pluginRegistry = new PluginRegistry(); diff --git a/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-unprotected-endpoint-authz.test.ts b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-unprotected-endpoint-authz.test.ts index 76882b8856b..190d44bf407 100644 --- a/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-unprotected-endpoint-authz.test.ts +++ b/packages/cactus-cmd-api-server/src/test/typescript/integration/jwt-unprotected-endpoint-authz.test.ts @@ -30,13 +30,13 @@ test(testCase, async (t: Test) => { try { const jwtKeyPair = await JWK.generate("RSA", 4096); const jwtPublicKey = jwtKeyPair.toPEM(false); - const middlewareOptions: expressJwt.Options = { + const expressJwtOptions: expressJwt.Options = { algorithms: ["RS256"], secret: jwtPublicKey, audience: uuidv4(), issuer: uuidv4(), }; - t.ok(middlewareOptions, "Express JWT config truthy OK"); + t.ok(expressJwtOptions, "Express JWT config truthy OK"); const ep = new UnprotectedActionEndpoint({ connector: {} as PluginLedgerConnectorStub, @@ -45,7 +45,8 @@ test(testCase, async (t: Test) => { const authorizationConfig: IAuthorizationConfig = { unprotectedEndpointExemptions: [ep.getPath()], - middlewareOptions, + expressJwtOptions, + socketIoJwtOptions: { secret: jwtPublicKey }, }; const pluginRegistry = new PluginRegistry();