diff --git a/packages/cactus-cmd-api-server/package.json b/packages/cactus-cmd-api-server/package.json index bc904396d8..964dc244f3 100644 --- a/packages/cactus-cmd-api-server/package.json +++ b/packages/cactus-cmd-api-server/package.json @@ -89,6 +89,7 @@ "fastify": "4.28.1", "fs-extra": "11.2.0", "google-protobuf": "3.21.4", + "http-status-codes": "2.3.0", "jose": "4.15.5", "json-stable-stringify": "1.0.2", "lmify": "0.3.0", diff --git a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/README.md b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/README.md index 548fe6c56c..00c6d62c51 100644 --- a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/README.md +++ b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/README.md @@ -91,7 +91,18 @@ Class | Method | HTTP request | Description ## Documentation For Authorization -Endpoints do not require authorization. + +Authentication schemes defined for the API: +### bearerTokenAuth + +- **Type**: HTTP Bearer token authentication + +Example + +```golang +auth := context.WithValue(context.Background(), sw.ContextAccessToken, "BEARER_TOKEN_STRING") +r, err := client.Service.Operation(auth, args) +``` ## Documentation for Utility Methods diff --git a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/api/openapi.yaml b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/api/openapi.yaml index b25ac93cc3..892145d84a 100644 --- a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/api/openapi.yaml +++ b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/api/openapi.yaml @@ -8,6 +8,11 @@ info: version: 2.0.0 servers: - url: / +security: +- bearerTokenAuth: + - read:health + - read:metrics + - read:spec paths: /api/v1/api-server/healthcheck: get: @@ -21,6 +26,13 @@ paths: schema: $ref: '#/components/schemas/HealthCheckResponse' description: OK + "401": + description: Unauthorized + "403": + description: Valid token but missing correct scope + security: + - bearerTokenAuth: + - read:health summary: Can be used to verify liveness of an API server instance x-hyperledger-cacti: http: @@ -37,6 +49,13 @@ paths: schema: $ref: '#/components/schemas/PrometheusExporterMetricsResponse' description: OK + "401": + description: Unauthorized + "403": + description: Valid token but missing correct scope + security: + - bearerTokenAuth: + - read:metrics summary: Get the Prometheus Metrics x-hyperledger-cacti: http: @@ -54,6 +73,13 @@ paths: schema: $ref: '#/components/schemas/GetOpenApiSpecV1EndpointResponse' description: OK + "401": + description: Unauthorized + "403": + description: Valid token but missing correct scope + security: + - bearerTokenAuth: + - read:spec x-hyperledger-cacti: http: verbLowerCase: get @@ -127,3 +153,8 @@ components: GetOpenApiSpecV1EndpointResponse: nullable: false type: string + securitySchemes: + bearerTokenAuth: + bearerFormat: JSON Web Tokens + scheme: bearer + type: http diff --git a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/client.go b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/client.go index a3b0f19744..b9e16de3b6 100644 --- a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/client.go +++ b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/client.go @@ -410,6 +410,11 @@ func (c *APIClient) prepareRequest( // Walk through any authentication. + // AccessToken Authentication + if auth, ok := ctx.Value(ContextAccessToken).(string); ok { + localVarRequest.Header.Add("Authorization", "Bearer "+auth) + } + } for header, value := range c.cfg.DefaultHeader { diff --git a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/configuration.go b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/configuration.go index c69a1313a5..6242dfec4f 100644 --- a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/configuration.go +++ b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/configuration.go @@ -28,6 +28,9 @@ func (c contextKey) String() string { } var ( + // ContextAccessToken takes a string oauth2 access token as authentication for the request. + ContextAccessToken = contextKey("accesstoken") + // ContextServerIndex uses a server configuration from the index. ContextServerIndex = contextKey("serverIndex") 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 a9c63cf9d2..3be247a00b 100644 --- a/packages/cactus-cmd-api-server/src/main/json/openapi.json +++ b/packages/cactus-cmd-api-server/src/main/json/openapi.json @@ -76,8 +76,20 @@ "type": "string", "nullable": false } + }, + "securitySchemes": { + "bearerTokenAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JSON Web Tokens" + } } }, + "security": [ + { + "bearerTokenAuth": ["read:health", "read:metrics", "read:spec"] + } + ], "paths": { "/api/v1/api-server/healthcheck": { "get": { @@ -101,8 +113,19 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Valid token but missing correct scope" } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:health"] + } + ] } }, "/api/v1/api-server/get-prometheus-exporter-metrics": { @@ -126,8 +149,19 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Valid token but missing correct scope" } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:metrics"] + } + ] } }, "/api/v1/api-server/get-open-api-spec": { @@ -151,8 +185,19 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Valid token but missing correct scope" } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:spec"] + } + ] } } } diff --git a/packages/cactus-cmd-api-server/src/main/json/openapi.tpl.json b/packages/cactus-cmd-api-server/src/main/json/openapi.tpl.json index a9c63cf9d2..3be247a00b 100644 --- a/packages/cactus-cmd-api-server/src/main/json/openapi.tpl.json +++ b/packages/cactus-cmd-api-server/src/main/json/openapi.tpl.json @@ -76,8 +76,20 @@ "type": "string", "nullable": false } + }, + "securitySchemes": { + "bearerTokenAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JSON Web Tokens" + } } }, + "security": [ + { + "bearerTokenAuth": ["read:health", "read:metrics", "read:spec"] + } + ], "paths": { "/api/v1/api-server/healthcheck": { "get": { @@ -101,8 +113,19 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Valid token but missing correct scope" } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:health"] + } + ] } }, "/api/v1/api-server/get-prometheus-exporter-metrics": { @@ -126,8 +149,19 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Valid token but missing correct scope" } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:metrics"] + } + ] } }, "/api/v1/api-server/get-open-api-spec": { @@ -151,8 +185,19 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Valid token but missing correct scope" } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:spec"] + } + ] } } } diff --git a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/README.md b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/README.md index a9770d9c2e..3159e738ac 100644 --- a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/README.md +++ b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/README.md @@ -60,5 +60,10 @@ Class | Method | HTTP request | Description ## Documentation for Authorization -Endpoints do not require authorization. + +Authentication schemes defined for the API: + +### bearerTokenAuth + +- **Type**: HTTP basic authentication diff --git a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/apis/DefaultApi.kt b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/apis/DefaultApi.kt index d05dc1394b..3631592222 100644 --- a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/apis/DefaultApi.kt +++ b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/apis/DefaultApi.kt @@ -108,7 +108,7 @@ class DefaultApi(basePath: kotlin.String = defaultBasePath, client: OkHttpClient path = "/api/v1/api-server/healthcheck", query = localVariableQuery, headers = localVariableHeaders, - requiresAuthentication = false, + requiresAuthentication = true, body = localVariableBody ) } @@ -176,7 +176,7 @@ class DefaultApi(basePath: kotlin.String = defaultBasePath, client: OkHttpClient path = "/api/v1/api-server/get-open-api-spec", query = localVariableQuery, headers = localVariableHeaders, - requiresAuthentication = false, + requiresAuthentication = true, body = localVariableBody ) } @@ -243,7 +243,7 @@ class DefaultApi(basePath: kotlin.String = defaultBasePath, client: OkHttpClient path = "/api/v1/api-server/get-prometheus-exporter-metrics", query = localVariableQuery, headers = localVariableHeaders, - requiresAuthentication = false, + requiresAuthentication = true, body = localVariableBody ) } diff --git a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/infrastructure/ApiClient.kt b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/infrastructure/ApiClient.kt index ea4b7b6593..c83d4d4d57 100644 --- a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/infrastructure/ApiClient.kt +++ b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/infrastructure/ApiClient.kt @@ -143,10 +143,20 @@ open class ApiClient(val baseUrl: String, val client: OkHttpClient = defaultClie } } + protected fun updateAuthParams(requestConfig: RequestConfig) { + if (requestConfig.headers[Authorization].isNullOrEmpty()) { + accessToken?.let { accessToken -> + requestConfig.headers[Authorization] = "Bearer $accessToken" + } + } + } protected inline fun request(requestConfig: RequestConfig): ApiResponse { val httpUrl = baseUrl.toHttpUrlOrNull() ?: throw IllegalStateException("baseUrl is invalid.") + // take authMethod from operation + updateAuthParams(requestConfig) + val url = httpUrl.newBuilder() .addEncodedPathSegments(requestConfig.path.trimStart('/')) .apply { 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 79a7e26341..76b0557385 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 @@ -81,6 +81,10 @@ import { GetOpenApiSpecV1Endpoint, IGetOpenApiSpecV1EndpointOptions, } from "./web-services/get-open-api-spec-v1-endpoint"; +import { + GetHealthcheckV1Endpoint, + IGetHealthcheckV1EndpointOptions, +} from "./web-services/get-healthcheck-v1-endpoint"; export interface IApiServerConstructorOptions { readonly pluginManagerOptions?: { pluginsPath: string }; @@ -640,6 +644,15 @@ export class ApiServer { const { logLevel } = this.options.config; const pluginRegistry = await this.getOrInitPluginRegistry(); + { + const opts: IGetHealthcheckV1EndpointOptions = { + process: global.process, + logLevel, + }; + const endpoint = new GetHealthcheckV1Endpoint(opts); + await registerWebServiceEndpoint(app, endpoint); + } + { const oasPath = OAS.paths["/api/v1/api-server/get-open-api-spec"]; @@ -657,23 +670,6 @@ export class ApiServer { await registerWebServiceEndpoint(app, endpoint); } - const healthcheckHandler = (req: Request, res: Response) => { - res.json({ - success: true, - createdAt: new Date(), - memoryUsage: process.memoryUsage(), - }); - }; - - const { "/api/v1/api-server/healthcheck": oasPath } = OAS.paths; - const { http } = oasPath.get["x-hyperledger-cacti"]; - const { path: httpPath, verbLowerCase: httpVerb } = http; - if (!isExpressHttpVerbMethodName(httpVerb)) { - const eMsg = `${fnTag} Invalid HTTP verb "${httpVerb}" in cmd-api-server OpenAPI specification for HTTP path: "${httpPath}"`; - throw new RuntimeError(eMsg); - } - app[httpVerb](httpPath, healthcheckHandler); - this.wsApi.on("connection", (socket: SocketIoSocket) => { const { id } = socket; const transport = socket.conn.transport.name; // in most cases, "polling" 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 6610e56b53..5d8ed9864a 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 @@ -128,6 +128,10 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + // authentication bearerTokenAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -157,6 +161,10 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + // authentication bearerTokenAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -187,6 +195,10 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + // authentication bearerTokenAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter); diff --git a/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-healthcheck-v1-endpoint.ts b/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-healthcheck-v1-endpoint.ts new file mode 100644 index 0000000000..f1ee542cda --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-healthcheck-v1-endpoint.ts @@ -0,0 +1,117 @@ +import { StatusCodes } from "http-status-codes"; +import type { Express, Request, Response } from "express"; + +import { + Checks, + IAsyncProvider, + Logger, + LoggerProvider, + LogLevelDesc, +} from "@hyperledger/cactus-common"; +import { + IEndpointAuthzOptions, + IExpressRequestHandler, + IWebServiceEndpoint, +} from "@hyperledger/cactus-core-api"; +import { + handleRestEndpointException, + IHandleRestEndpointExceptionOptions, + registerWebServiceEndpoint, +} from "@hyperledger/cactus-core"; + +import OAS from "../../json/openapi.json"; + +export interface IGetHealthcheckV1EndpointOptions { + readonly logLevel?: LogLevelDesc; + readonly process: NodeJS.Process; +} + +export class GetHealthcheckV1Endpoint implements IWebServiceEndpoint { + public static readonly CLASS_NAME = "GetHealthcheckV1Endpoint"; + + private readonly log: Logger; + + private readonly process: NodeJS.Process; + + public get className(): string { + return GetHealthcheckV1Endpoint.CLASS_NAME; + } + + constructor(public readonly opts: IGetHealthcheckV1EndpointOptions) { + const fnTag = `${this.className}#constructor()`; + Checks.truthy(opts, `${fnTag} arg opts`); + Checks.truthy(opts.process, `${fnTag} arg opts.process`); + + this.process = opts.process; + + const level = this.opts.logLevel || "INFO"; + const label = this.className; + this.log = LoggerProvider.getOrCreate({ level, label }); + } + + public getAuthorizationOptionsProvider(): IAsyncProvider { + return { + get: async () => ({ + isProtected: true, + requiredRoles: this.oasPath.get.security[0].bearerTokenAuth, + }), + }; + } + + public getExpressRequestHandler(): IExpressRequestHandler { + return this.handleRequest.bind(this); + } + + public get oasPath(): (typeof OAS.paths)["/api/v1/api-server/healthcheck"] { + return OAS.paths["/api/v1/api-server/healthcheck"]; + } + + public getPath(): string { + return this.oasPath.get["x-hyperledger-cacti"].http.path; + } + + public getVerbLowerCase(): string { + return this.oasPath.get["x-hyperledger-cacti"].http.verbLowerCase; + } + + public getOperationId(): string { + return this.oasPath.get.operationId; + } + + public async registerExpress( + expressApp: Express, + ): Promise { + await registerWebServiceEndpoint(expressApp, this); + return this; + } + + async handleRequest(_req: Request, res: Response): Promise { + const fnTag = `${this.className}#handleRequest()`; + const verbUpper = this.getVerbLowerCase().toUpperCase(); + const reqTag = `${verbUpper} ${this.getPath()}`; + this.log.debug(reqTag); + + try { + const memoryUsage = this.process.memoryUsage(); + const createdAt = new Date(); + const body = { + success: true, + createdAt, + memoryUsage, + }; + res.json(body).status(StatusCodes.OK); + } catch (error) { + const { log } = this; + const errorMsg = `${fnTag} request handler fn crashed for: ${reqTag}`; + + const ctx: Readonly = { + errorMsg, + log, + error, + res, + }; + + await handleRestEndpointException(ctx); + } + } +} diff --git a/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-open-api-spec-v1-endpoint.ts b/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-open-api-spec-v1-endpoint.ts index 79b3e6d4b0..c71993819b 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-open-api-spec-v1-endpoint.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-open-api-spec-v1-endpoint.ts @@ -3,8 +3,15 @@ import { IGetOpenApiSpecV1EndpointBaseOptions, } from "@hyperledger/cactus-core"; -import { Checks, LogLevelDesc } from "@hyperledger/cactus-common"; -import { IWebServiceEndpoint } from "@hyperledger/cactus-core-api"; +import { + Checks, + IAsyncProvider, + LogLevelDesc, +} from "@hyperledger/cactus-common"; +import { + IEndpointAuthzOptions, + IWebServiceEndpoint, +} from "@hyperledger/cactus-core-api"; import OAS from "../../json/openapi.json"; @@ -34,4 +41,13 @@ export class GetOpenApiSpecV1Endpoint const fnTag = `${this.className}#constructor()`; Checks.truthy(options, `${fnTag} arg options`); } + + public getAuthorizationOptionsProvider(): IAsyncProvider { + return { + get: async () => ({ + isProtected: true, + requiredRoles: this.opts.oasPath.get.security[0].bearerTokenAuth, + }), + }; + } } diff --git a/packages/cactus-cmd-api-server/src/test/typescript/benchmark/run-cmd-api-server-benchmark.ts b/packages/cactus-cmd-api-server/src/test/typescript/benchmark/run-cmd-api-server-benchmark.ts index 2babf8ff13..70bd10444d 100644 --- a/packages/cactus-cmd-api-server/src/test/typescript/benchmark/run-cmd-api-server-benchmark.ts +++ b/packages/cactus-cmd-api-server/src/test/typescript/benchmark/run-cmd-api-server-benchmark.ts @@ -104,7 +104,11 @@ const createTestInfrastructure = async (opts: { const grpcHost = `${addressInfoGrpc.address}:${addressInfoGrpc.port}`; - const jwtPayload = { name: "Peter", location: "Albertirsa" }; + const jwtPayload = { + name: "Peter", + location: "London", + scope: "read:spec", + }; const validJwt = await new SignJWT(jwtPayload) .setProtectedHeader({ alg: "RS256" }) .setIssuer(expressJwtOptions.issuer) 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 2e0d8c7e57..555520c3d6 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 @@ -87,7 +87,11 @@ describe(testCase, () => { try { expect(expressJwtOptions).toBeTruthy(); - const jwtPayload = { name: "Peter", location: "London" }; + const jwtPayload = { + name: "Peter", + location: "London", + scope: "read:health", + }; const tokenGood = await new SignJWT(jwtPayload) .setProtectedHeader({ alg: "RS256", @@ -100,7 +104,6 @@ describe(testCase, () => { const startResponse = apiServer.start(); await expect(startResponse).not.toReject; expect(startResponse).toBeTruthy(); - const addressInfoApi = (await startResponse).addressInfoApi; const protocol = apiSrvOpts.apiTlsEnabled ? "https" : "http"; const { address, port } = addressInfoApi; 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 index 38b636d7c4..2fb27ed2fe 100644 --- 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 @@ -84,7 +84,11 @@ describe("cmd-api-server:ApiServer", () => { const { address, port } = addressInfoApi; apiHost = `${protocol}://${address}:${port}`; - const jwtPayload = { name: "Peter", location: "Albertirsa" }; + const jwtPayload = { + name: "Peter", + location: "London", + scope: "read:health", + }; const validJwt = await new SignJWT(jwtPayload) .setProtectedHeader({ alg: "RS256" }) .setIssuer(expressJwtOptions.issuer) diff --git a/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-endpoint.test.ts b/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-endpoint.test.ts index 5b44fa205a..70ae5e0a8d 100644 --- a/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-endpoint.test.ts +++ b/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-endpoint.test.ts @@ -101,7 +101,11 @@ describe("cmd-api-server:getOpenApiSpecV1Endpoint", () => { grpcHost = `${addressInfoGrpc.address}:${addressInfoGrpc.port}`; - const jwtPayload = { name: "Peter", location: "Albertirsa" }; + const jwtPayload = { + name: "Peter", + location: "London", + scope: "read:spec", + }; const validJwt = await new SignJWT(jwtPayload) .setProtectedHeader({ alg: "RS256" }) .setIssuer(expressJwtOptions.issuer) diff --git a/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-oauth2-scopes.test.ts b/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-oauth2-scopes.test.ts new file mode 100644 index 0000000000..c5f69dc4f7 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-oauth2-scopes.test.ts @@ -0,0 +1,274 @@ +import { + ApiServer, + ApiServerApiClient, + ApiServerApiClientConfiguration, + AuthorizationProtocol, + ConfigService, + IAuthorizationConfig, +} from "../../../main/typescript/public-api"; +import { + IJoseFittingJwtParams, + LogLevelDesc, +} from "@hyperledger/cactus-common"; +import { PluginRegistry } from "@hyperledger/cactus-core"; +import { Constants } from "@hyperledger/cactus-core-api"; +import type { AuthorizeOptions as SocketIoJwtOptions } from "@thream/socketio-jwt"; +import type { Params as ExpressJwtOptions } from "express-jwt"; +import "jest-extended"; +import { SignJWT, exportSPKI, generateKeyPair } from "jose"; +import path from "path"; +import { v4 as uuidv4 } from "uuid"; + +describe("cmd-api-server:getOpenApiSpecV1Endpoint", () => { + const logLevel: LogLevelDesc = "INFO"; + let apiServer: ApiServer; + let apiClient: ApiServerApiClient; + let jwtKeyPair: { publicKey: CryptoKey; privateKey: CryptoKey }; + let expressJwtOptions: ExpressJwtOptions & IJoseFittingJwtParams; + + afterAll(async () => await apiServer.shutdown()); + + beforeAll(async () => { + jwtKeyPair = await generateKeyPair("RS256", { modulusLength: 4096 }); + const jwtPublicKey = await exportSPKI(jwtKeyPair.publicKey); + + expressJwtOptions = { + algorithms: ["RS256"], + secret: jwtPublicKey, + audience: uuidv4(), + issuer: uuidv4(), + }; + + const socketIoJwtOptions: SocketIoJwtOptions = { + secret: jwtPublicKey, + algorithms: ["RS256"], + }; + expect(expressJwtOptions).toBeTruthy(); + + const authorizationConfig: IAuthorizationConfig = { + unprotectedEndpointExemptions: [], + expressJwtOptions, + socketIoJwtOptions, + socketIoPath: Constants.SocketIoConnectionPathV1, + }; + + const pluginsPath = path.join( + __dirname, + "../../../../../../", // walk back up to the project root + ".tmp/test/test-cmd-api-server/get-open-api-spec-v1-endpoint_test/", // the dir path from the root + uuidv4(), // then a random directory to ensure proper isolation + ); + const pluginManagerOptionsJson = JSON.stringify({ pluginsPath }); + + const pluginRegistry = new PluginRegistry({ logLevel }); + + const configService = new ConfigService(); + + const apiSrvOpts = await configService.newExampleConfig(); + apiSrvOpts.logLevel = logLevel; + apiSrvOpts.pluginManagerOptionsJson = pluginManagerOptionsJson; + apiSrvOpts.authorizationProtocol = AuthorizationProtocol.JSON_WEB_TOKEN; + apiSrvOpts.authorizationConfigJson = authorizationConfig; + apiSrvOpts.configFile = ""; + apiSrvOpts.apiCorsDomainCsv = "*"; + apiSrvOpts.apiPort = 0; + apiSrvOpts.cockpitPort = 0; + apiSrvOpts.grpcPort = 0; + apiSrvOpts.crpcPort = 0; + apiSrvOpts.apiTlsEnabled = false; + apiSrvOpts.grpcMtlsEnabled = false; + apiSrvOpts.plugins = []; + + const config = await configService.newExampleConfigConvict(apiSrvOpts); + + apiServer = new ApiServer({ + config: config.getProperties(), + pluginRegistry, + }); + + apiServer.initPluginRegistry({ pluginRegistry }); + const startResponsePromise = apiServer.start(); + await expect(startResponsePromise).toResolve(); + const startResponse = await startResponsePromise; + expect(startResponse).toBeTruthy(); + + const { addressInfoApi } = await startResponsePromise; + const protocol = apiSrvOpts.apiTlsEnabled ? "https" : "http"; + const { address, port } = addressInfoApi; + const apiHost = `${protocol}://${address}:${port}`; + + const jwtPayload = { name: "Peter", location: "Albertirsa" }; + const validJwt = await new SignJWT(jwtPayload) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(expressJwtOptions.issuer) + .setAudience(expressJwtOptions.audience) + .sign(jwtKeyPair.privateKey); + expect(validJwt).toBeTruthy(); + + const validBearerToken = `Bearer ${validJwt}`; + expect(validBearerToken).toBeTruthy(); + + apiClient = new ApiServerApiClient( + new ApiServerApiClientConfiguration({ + basePath: apiHost, + baseOptions: { headers: { Authorization: validBearerToken } }, + logLevel, + }), + ); + }); + + it("HTTP - allows request execution with a valid JWT Token", async () => { + const jwtPayload = { scope: "read:spec" }; + const validJwt = await new SignJWT(jwtPayload) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(expressJwtOptions.issuer) + .setAudience(expressJwtOptions.audience) + .sign(jwtKeyPair.privateKey); + + const validBearerToken = `Bearer ${validJwt}`; + expect(validBearerToken).toBeTruthy(); + + const res3Promise = apiClient.getOpenApiSpecV1({ + headers: { Authorization: validBearerToken }, + }); + + await expect(res3Promise).resolves.toHaveProperty("data.openapi"); + const res3 = await res3Promise; + expect(res3.status).toEqual(200); + expect(res3.data).toBeTruthy(); + }); + + it("HTTP - rejects request with an valid JWT but incorrect scope", async () => { + const jwtPayload = { scope: "red:specs" }; + const validJwt = await new SignJWT(jwtPayload) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(expressJwtOptions.issuer) + .setAudience(expressJwtOptions.audience) + .sign(jwtKeyPair.privateKey); + + const validBearerToken = `Bearer ${validJwt}`; + expect(validBearerToken).toBeTruthy(); + + const res3Promise = apiClient.getOpenApiSpecV1({ + headers: { Authorization: validBearerToken }, + }); + await expect(res3Promise).rejects.toThrow(); + try { + await res3Promise; + } catch (error) { + const statusCode = error.response?.status; + console.log( + `Request failed with valid JWT but incorrect scope status code: ${statusCode}`, + ); + } + }); + + it("HTTP - rejects request with an invalid JWT", async () => { + const { privateKey: otherPrivateKey } = await generateKeyPair("RS256"); + const invalidJwt = await new SignJWT({ scope: "invalid:scope" }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer("invalid-issuer") + .setAudience("invalid-audience") + .sign(otherPrivateKey); + + const invalidBearerToken = `Bearer ${invalidJwt}`; + expect(invalidBearerToken).toBeTruthy(); + + const res3Promise = apiClient.getOpenApiSpecV1({ + headers: { Authorization: invalidBearerToken }, + }); + await expect(res3Promise).rejects.toThrow( + "Request failed with status code 401", + ); + }); + + it("HTTP - allows health check execution with a valid JWT Token", async () => { + const jwtPayload = { scope: "read:health" }; + const validJwt = await new SignJWT(jwtPayload) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(expressJwtOptions.issuer) + .setAudience(expressJwtOptions.audience) + .sign(jwtKeyPair.privateKey); + + const validBearerToken = `Bearer ${validJwt}`; + expect(validBearerToken).toBeTruthy(); + + const resPromise = apiClient.getHealthCheckV1({ + headers: { Authorization: validBearerToken }, + }); + + await expect(resPromise).resolves.toHaveProperty("data"); + const res = await resPromise; + expect(res.status).toEqual(200); + expect(res.data).toBeTruthy(); + }); + + it("HTTP - rejects health check execution with an invalid JWT", async () => { + const { privateKey: otherPrivateKey } = await generateKeyPair("RS256"); + const invalidJwt = await new SignJWT({ scope: "invalid:scope" }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer("invalid-issuer") + .setAudience("invalid-audience") + .sign(otherPrivateKey); + + const invalidBearerToken = `Bearer ${invalidJwt}`; + expect(invalidBearerToken).toBeTruthy(); + + const resPromise = apiClient.getHealthCheckV1({ + headers: { Authorization: invalidBearerToken }, + }); + await expect(resPromise).rejects.toThrow(); + + try { + await resPromise; + } catch (error) { + const statusCode = error.response?.status; + console.log(`Request failed with status code: ${statusCode}`); + } + }); + + it("HTTP - allows Prometheus metrics execution with a valid JWT Token", async () => { + const jwtPayload = { scope: "read:metrics" }; + const validJwt = await new SignJWT(jwtPayload) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(expressJwtOptions.issuer) + .setAudience(expressJwtOptions.audience) + .sign(jwtKeyPair.privateKey); + + const validBearerToken = `Bearer ${validJwt}`; + expect(validBearerToken).toBeTruthy(); + + const resPromise = apiClient.getPrometheusMetricsV1({ + headers: { Authorization: validBearerToken }, + }); + + await expect(resPromise).resolves.toHaveProperty("data"); + const res = await resPromise; + expect(res.status).toEqual(200); + expect(res.data).toBeTruthy(); + }); + + it("HTTP - rejects Prometheus metrics execution with an invalid JWT", async () => { + const { privateKey: otherPrivateKey } = await generateKeyPair("RS256"); + const invalidJwt = await new SignJWT({ scope: "invalid:scope" }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer("invalid-issuer") + .setAudience("invalid-audience") + .sign(otherPrivateKey); + + const invalidBearerToken = `Bearer ${invalidJwt}`; + expect(invalidBearerToken).toBeTruthy(); + + const resPromise = apiClient.getPrometheusMetricsV1({ + headers: { Authorization: invalidBearerToken }, + }); + await expect(resPromise).rejects.toThrow(); + + try { + await resPromise; + } catch (error) { + const statusCode = error.response?.status; + console.log(`Request failed with status code: ${statusCode}`); + } + }); +});