From af634c33f72e1cdae6ed4962d65ac373d720ac1d Mon Sep 17 00:00:00 2001 From: aldousalvarez Date: Fri, 6 Sep 2024 11:40:13 +0800 Subject: [PATCH] refactor(cmd-api-server): pull OAuth2 endpoint scopes from openapi.json Primary Changes ---------------- 1. added OAuth2 security endpoints scopes to openapi.json 2. added a test to make sure if the scopes are indeed getting pulled from the spec file Fixes #2693 Signed-off-by: aldousalvarez 1. Please also refactor the third endpoint (prometheus metrics) accordingly 2. Also please extend the test case with each tokens having specific scopes and then assert that the tokesn with the correct scopes work and the ones that don't have the correct scopes do not even when they are otherwise valid tokens. Signed-off-by: Peter Somogyvari --- .../go/generated/openapi/go-client/README.md | 13 +- .../openapi/go-client/api/openapi.yaml | 25 +++ .../go/generated/openapi/go-client/client.go | 5 + .../openapi/go-client/configuration.go | 3 + .../src/main/json/openapi.json | 42 +++- .../src/main/json/openapi.tpl.json | 42 +++- .../generated/openapi/kotlin-client/README.md | 7 +- .../openapitools/client/apis/DefaultApi.kt | 6 +- .../client/infrastructure/ApiClient.kt | 10 + .../src/main/typescript/api-server.ts | 30 ++- .../generated/openapi/typescript-axios/api.ts | 12 + .../get-healthcheck-v1-endpoint.ts | 117 ++++++++++ .../get-open-api-spec-v1-endpoint.ts | 20 +- ...get-open-api-spec-v1-oauth2-scopes.test.ts | 209 ++++++++++++++++++ 14 files changed, 511 insertions(+), 30 deletions(-) create mode 100644 packages/cactus-cmd-api-server/src/main/typescript/web-services/get-healthcheck-v1-endpoint.ts create mode 100644 packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-oauth2-scopes.test.ts 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 a79cc0d9be3..43a274e5615 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 8ff5b5f26b1..576280acbac 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-rc.7 servers: - url: / +security: +- bearerTokenAuth: + - read:health + - read:metrics + - read:spec paths: /api/v1/api-server/healthcheck: get: @@ -21,6 +26,11 @@ paths: schema: $ref: '#/components/schemas/HealthCheckResponse' description: OK + "401": + description: Unauthorized + security: + - bearerTokenAuth: + - read:health summary: Can be used to verify liveness of an API server instance x-hyperledger-cacti: http: @@ -37,6 +47,11 @@ paths: schema: $ref: '#/components/schemas/PrometheusExporterMetricsResponse' description: OK + "401": + description: Unauthorized + security: + - OAuth2: + - read:metrics summary: Get the Prometheus Metrics x-hyperledger-cacti: http: @@ -54,6 +69,11 @@ paths: schema: $ref: '#/components/schemas/GetOpenApiSpecV1EndpointResponse' description: OK + "401": + description: Unauthorized + security: + - bearerTokenAuth: + - read:spec x-hyperledger-cacti: http: verbLowerCase: get @@ -127,3 +147,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 70a4452d681..758e67f1384 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 7e319b7cbfa..91b1c589ce4 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 8e52321f09b..cda0f7eb923 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,16 @@ } } } + }, + "401": { + "description": "Unauthorized" } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:health"] + } + ] } }, "/api/v1/api-server/get-prometheus-exporter-metrics": { @@ -126,8 +146,16 @@ } } } + }, + "401": { + "description": "Unauthorized" } - } + }, + "security": [ + { + "OAuth2": ["read:metrics"] + } + ] } }, "/api/v1/api-server/get-open-api-spec": { @@ -151,8 +179,16 @@ } } } + }, + "401": { + "description": "Unauthorized" } - } + }, + "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 8e52321f09b..cda0f7eb923 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,16 @@ } } } + }, + "401": { + "description": "Unauthorized" } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:health"] + } + ] } }, "/api/v1/api-server/get-prometheus-exporter-metrics": { @@ -126,8 +146,16 @@ } } } + }, + "401": { + "description": "Unauthorized" } - } + }, + "security": [ + { + "OAuth2": ["read:metrics"] + } + ] } }, "/api/v1/api-server/get-open-api-spec": { @@ -151,8 +179,16 @@ } } } + }, + "401": { + "description": "Unauthorized" } - } + }, + "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 849797d5840..284fb09568b 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 d05dc1394b8..36315922220 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 ea4b7b65935..c83d4d4d575 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 c49ad51ba91..1e1333ea90d 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 159894ea96a..9531721455f 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 00000000000..f1ee542cdaf --- /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 79b3e6d4b0c..c71993819b0 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/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 00000000000..40145339046 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-oauth2-scopes.test.ts @@ -0,0 +1,209 @@ +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"; + +interface IExpectedScopes { + [key: string]: string; +} + +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 - returns the OpenAPI spec .json document of the API server itself", async () => { + const res1Promise = apiClient.getOpenApiSpecV1(); + await expect(res1Promise).resolves.toHaveProperty("data.openapi"); + const res1 = await res1Promise; + expect(res1.status).toEqual(200); + expect(res1.data).toBeTruthy(); + + console.log("Response data type:", typeof res1.data); + console.log("Response data:", res1.data); + + let openApiSpec; + try { + openApiSpec = + typeof res1.data === "string" ? JSON.parse(res1.data) : res1.data; + } catch (error) { + throw new Error(`Failed to parse OpenAPI spec: ${error.message}`); + } + + expect(openApiSpec).toHaveProperty("components"); + expect(openApiSpec.components).toHaveProperty("securitySchemes"); + + const securitySchemes = openApiSpec.components.securitySchemes; + expect(securitySchemes).toBeObject(); + + const expectedScopes: IExpectedScopes = { + "read:health": "Read health information", + "read:metrics": "Read metrics information", + "read:spec": "Read OpenAPI specification", + }; + + const securitySchemeNames = Object.keys(securitySchemes); + + securitySchemeNames.forEach((schemeName) => { + const scheme = securitySchemes[schemeName]; + expect(scheme).toHaveProperty("flows"); + const flows = scheme.flows; + expect(flows).toHaveProperty("authorizationCode"); + const scopes = flows.authorizationCode.scopes as IExpectedScopes; + + Object.keys(expectedScopes).forEach((scope) => { + expect(scopes).toHaveProperty(scope); + expect(scopes[scope]).toEqual(expectedScopes[scope]); + }); + }); + }); + + 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 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", + ); + }); +});