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", + ); + }); +});