From b03c01491736cf0d0703388415d72b72ab83ba84 Mon Sep 17 00:00:00 2001 From: aldousalvarez Date: Thu, 10 Oct 2024 15:54:02 +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 --- .github/workflows/ci.yaml | 2 +- packages/cactus-cmd-api-server/package.json | 2 +- .../go-client/.openapi-generator/FILES | 1 + .../go/generated/openapi/go-client/README.md | 14 +- .../openapi/go-client/api/openapi.yaml | 61 ++++ .../openapi/go-client/api_default.go | 69 ++++- .../go/generated/openapi/go-client/client.go | 5 + .../openapi/go-client/configuration.go | 3 + ..._cmd_api_server_endpoint_error_response.go | 126 ++++++++ .../src/main/json/openapi.json | 101 ++++++- .../src/main/json/openapi.tpl.json | 101 ++++++- .../kotlin-client/.openapi-generator/FILES | 1 + .../generated/openapi/kotlin-client/README.md | 8 +- .../openapitools/client/apis/DefaultApi.kt | 7 +- .../client/infrastructure/ApiClient.kt | 10 + .../CmdApiServerEndpointErrorResponse.kt | 35 +++ .../openapi/.openapi-generator/FILES | 1 + ...pi_server_endpoint_error_response_pb.proto | 19 ++ .../openapi/services/default_service.proto | 1 + .../src/main/typescript/api-server.ts | 30 +- .../generated/openapi/typescript-axios/api.ts | 25 ++ ...d_api_server_endpoint_error_response_pb.ts | 75 +++++ .../protoc-gen-ts/services/default_service.ts | 11 +- .../get-healthcheck-v1-endpoint.ts | 117 ++++++++ .../get-open-api-spec-v1-endpoint.ts | 20 +- .../benchmark/run-cmd-api-server-benchmark.ts | 6 +- .../jwt-endpoint-authorization.test.ts | 7 +- ...wt-socketio-endpoint-authorization.test.ts | 6 +- .../get-open-api-spec-v1-endpoint.test.ts | 6 +- ...get-open-api-spec-v1-oauth2-scopes.test.ts | 274 ++++++++++++++++++ yarn.lock | 9 +- 31 files changed, 1107 insertions(+), 46 deletions(-) create mode 100644 packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/model_cmd_api_server_endpoint_error_response.go create mode 100644 packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/models/CmdApiServerEndpointErrorResponse.kt create mode 100644 packages/cactus-cmd-api-server/src/main/proto/generated/openapi/models/cmd_api_server_endpoint_error_response_pb.proto create mode 100644 packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/models/cmd_api_server_endpoint_error_response_pb.ts 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/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5f3c1e4effb..463c3fc6aa1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -493,7 +493,7 @@ jobs: --tag cmd-api-server \ --tag "ghcr.io/hyperledger/cactus-cmd-api-server:$(date +"%Y-%m-%dT%H-%M-%S" --utc)-dev-$(git rev-parse --short HEAD)" - - if: ${{ env.RUN_TRIVY_SCAN == 'true' }} + - if: ${{ env.RUN_TRIVY_SCAN == 'true' }} && env.DAY_OF_WEEK == '6' || env.DAY_OF_WEEK == '7' && env.CURRENT_HOUR == '15' name: Run Trivy vulnerability scan for cmd-api-server uses: aquasecurity/trivy-action@0.19.0 with: diff --git a/packages/cactus-cmd-api-server/package.json b/packages/cactus-cmd-api-server/package.json index bc904396d85..3b283060b9d 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.1.3", "jose": "4.15.5", "json-stable-stringify": "1.0.2", "lmify": "0.3.0", @@ -134,7 +135,6 @@ "google-protobuf": "3.21.4", "grpc-tools": "1.12.4", "grpc_tools_node_protoc_ts": "5.3.3", - "http-status-codes": "2.1.4", "protobufjs": "7.4.0", "tsx": "4.16.2" }, diff --git a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/.openapi-generator/FILES b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/.openapi-generator/FILES index 8c0f008a05f..3b1e0f7513b 100644 --- a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/.openapi-generator/FILES +++ b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/.openapi-generator/FILES @@ -6,6 +6,7 @@ client.go configuration.go go.mod go.sum +model_cmd_api_server_endpoint_error_response.go model_health_check_response.go model_memory_usage.go model_watch_healthcheck_v1.go 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 548fe6c56c8..2eff5f1a8e8 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 @@ -84,6 +84,7 @@ Class | Method | HTTP request | Description ## Documentation For Models + - [CmdApiServerEndpointErrorResponse](docs/CmdApiServerEndpointErrorResponse.md) - [HealthCheckResponse](docs/HealthCheckResponse.md) - [MemoryUsage](docs/MemoryUsage.md) - [WatchHealthcheckV1](docs/WatchHealthcheckV1.md) @@ -91,7 +92,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 b25ac93cc3c..b3391e5b6c4 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,21 @@ paths: schema: $ref: '#/components/schemas/HealthCheckResponse' description: OK + "401": + content: + '*/*': + schema: + $ref: '#/components/schemas/CmdApiServerEndpointErrorResponse' + description: Unauthorized - Invalid token + "403": + content: + '*/*': + schema: + $ref: '#/components/schemas/CmdApiServerEndpointErrorResponse' + description: Forbidden - 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 +57,21 @@ paths: schema: $ref: '#/components/schemas/PrometheusExporterMetricsResponse' description: OK + "401": + content: + '*/*': + schema: + $ref: '#/components/schemas/CmdApiServerEndpointErrorResponse' + description: Unauthorized - Invalid token + "403": + content: + '*/*': + schema: + $ref: '#/components/schemas/CmdApiServerEndpointErrorResponse' + description: Forbidden - Valid token but missing correct scope + security: + - bearerTokenAuth: + - read:metrics summary: Get the Prometheus Metrics x-hyperledger-cacti: http: @@ -54,6 +89,21 @@ paths: schema: $ref: '#/components/schemas/GetOpenApiSpecV1EndpointResponse' description: OK + "401": + content: + '*/*': + schema: + $ref: '#/components/schemas/CmdApiServerEndpointErrorResponse' + description: Unauthorized - Invalid token + "403": + content: + '*/*': + schema: + $ref: '#/components/schemas/CmdApiServerEndpointErrorResponse' + description: Forbidden - Valid token but missing correct scope + security: + - bearerTokenAuth: + - read:spec x-hyperledger-cacti: http: verbLowerCase: get @@ -127,3 +177,14 @@ components: GetOpenApiSpecV1EndpointResponse: nullable: false type: string + CmdApiServerEndpointErrorResponse: + properties: + message: + example: | + Forbidden - Valid token but missing correct scope + 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/api_default.go b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/api_default.go index 27da765c0c8..58c64eaeed9 100644 --- a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/api_default.go +++ b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/api_default.go @@ -77,7 +77,7 @@ func (a *DefaultApiService) GetHealthCheckV1Execute(r ApiGetHealthCheckV1Request } // to determine the Accept header - localVarHTTPHeaderAccepts := []string{"application/json"} + localVarHTTPHeaderAccepts := []string{"application/json", "*/*"} // set Accept header localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) @@ -106,6 +106,27 @@ func (a *DefaultApiService) GetHealthCheckV1Execute(r ApiGetHealthCheckV1Request body: localVarBody, error: localVarHTTPResponse.Status, } + if localVarHTTPResponse.StatusCode == 401 { + var v CmdApiServerEndpointErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v CmdApiServerEndpointErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } return localVarReturnValue, localVarHTTPResponse, newErr } @@ -176,7 +197,7 @@ func (a *DefaultApiService) GetOpenApiSpecV1Execute(r ApiGetOpenApiSpecV1Request } // to determine the Accept header - localVarHTTPHeaderAccepts := []string{"application/json"} + localVarHTTPHeaderAccepts := []string{"application/json", "*/*"} // set Accept header localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) @@ -205,6 +226,27 @@ func (a *DefaultApiService) GetOpenApiSpecV1Execute(r ApiGetOpenApiSpecV1Request body: localVarBody, error: localVarHTTPResponse.Status, } + if localVarHTTPResponse.StatusCode == 401 { + var v CmdApiServerEndpointErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v CmdApiServerEndpointErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } return localVarReturnValue, localVarHTTPResponse, newErr } @@ -273,7 +315,7 @@ func (a *DefaultApiService) GetPrometheusMetricsV1Execute(r ApiGetPrometheusMetr } // to determine the Accept header - localVarHTTPHeaderAccepts := []string{"text/plain"} + localVarHTTPHeaderAccepts := []string{"text/plain", "*/*"} // set Accept header localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) @@ -302,6 +344,27 @@ func (a *DefaultApiService) GetPrometheusMetricsV1Execute(r ApiGetPrometheusMetr body: localVarBody, error: localVarHTTPResponse.Status, } + if localVarHTTPResponse.StatusCode == 401 { + var v CmdApiServerEndpointErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v CmdApiServerEndpointErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } return localVarReturnValue, localVarHTTPResponse, newErr } 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 a3b0f197443..b9e16de3b6a 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 c69a1313a52..6242dfec4f9 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/go/generated/openapi/go-client/model_cmd_api_server_endpoint_error_response.go b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/model_cmd_api_server_endpoint_error_response.go new file mode 100644 index 00000000000..d3fa00b9c9a --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/model_cmd_api_server_endpoint_error_response.go @@ -0,0 +1,126 @@ +/* +Hyperledger Cactus API + +Interact with a Cactus deployment through HTTP. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package cactus-cmd-api-server + +import ( + "encoding/json" +) + +// checks if the CmdApiServerEndpointErrorResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &CmdApiServerEndpointErrorResponse{} + +// CmdApiServerEndpointErrorResponse struct for CmdApiServerEndpointErrorResponse +type CmdApiServerEndpointErrorResponse struct { + Message *string `json:"message,omitempty"` +} + +// NewCmdApiServerEndpointErrorResponse instantiates a new CmdApiServerEndpointErrorResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewCmdApiServerEndpointErrorResponse() *CmdApiServerEndpointErrorResponse { + this := CmdApiServerEndpointErrorResponse{} + return &this +} + +// NewCmdApiServerEndpointErrorResponseWithDefaults instantiates a new CmdApiServerEndpointErrorResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewCmdApiServerEndpointErrorResponseWithDefaults() *CmdApiServerEndpointErrorResponse { + this := CmdApiServerEndpointErrorResponse{} + return &this +} + +// GetMessage returns the Message field value if set, zero value otherwise. +func (o *CmdApiServerEndpointErrorResponse) GetMessage() string { + if o == nil || IsNil(o.Message) { + var ret string + return ret + } + return *o.Message +} + +// GetMessageOk returns a tuple with the Message field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *CmdApiServerEndpointErrorResponse) GetMessageOk() (*string, bool) { + if o == nil || IsNil(o.Message) { + return nil, false + } + return o.Message, true +} + +// HasMessage returns a boolean if a field has been set. +func (o *CmdApiServerEndpointErrorResponse) HasMessage() bool { + if o != nil && !IsNil(o.Message) { + return true + } + + return false +} + +// SetMessage gets a reference to the given string and assigns it to the Message field. +func (o *CmdApiServerEndpointErrorResponse) SetMessage(v string) { + o.Message = &v +} + +func (o CmdApiServerEndpointErrorResponse) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o CmdApiServerEndpointErrorResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Message) { + toSerialize["message"] = o.Message + } + return toSerialize, nil +} + +type NullableCmdApiServerEndpointErrorResponse struct { + value *CmdApiServerEndpointErrorResponse + isSet bool +} + +func (v NullableCmdApiServerEndpointErrorResponse) Get() *CmdApiServerEndpointErrorResponse { + return v.value +} + +func (v *NullableCmdApiServerEndpointErrorResponse) Set(val *CmdApiServerEndpointErrorResponse) { + v.value = val + v.isSet = true +} + +func (v NullableCmdApiServerEndpointErrorResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableCmdApiServerEndpointErrorResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableCmdApiServerEndpointErrorResponse(val *CmdApiServerEndpointErrorResponse) *NullableCmdApiServerEndpointErrorResponse { + return &NullableCmdApiServerEndpointErrorResponse{value: val, isSet: true} +} + +func (v NullableCmdApiServerEndpointErrorResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableCmdApiServerEndpointErrorResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + 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 a9c63cf9d26..d4ee7c7681b 100644 --- a/packages/cactus-cmd-api-server/src/main/json/openapi.json +++ b/packages/cactus-cmd-api-server/src/main/json/openapi.json @@ -75,9 +75,29 @@ "GetOpenApiSpecV1EndpointResponse": { "type": "string", "nullable": false + }, + "CmdApiServerEndpointErrorResponse": { + "properties": { + "message": { + "type": "string", + "example": "Forbidden - Valid token but missing correct scope\n" + } + } + } + }, + "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 +121,33 @@ } } } + }, + "401": { + "description": "Unauthorized - Invalid token", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden - Valid token but missing correct scope", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:health"] + } + ] } }, "/api/v1/api-server/get-prometheus-exporter-metrics": { @@ -126,8 +171,33 @@ } } } + }, + "401": { + "description": "Unauthorized - Invalid token", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden - Valid token but missing correct scope", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:metrics"] + } + ] } }, "/api/v1/api-server/get-open-api-spec": { @@ -151,8 +221,33 @@ } } } + }, + "401": { + "description": "Unauthorized - Invalid token", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden - Valid token but missing correct scope", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } } - } + }, + "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 a9c63cf9d26..d4ee7c7681b 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 @@ -75,9 +75,29 @@ "GetOpenApiSpecV1EndpointResponse": { "type": "string", "nullable": false + }, + "CmdApiServerEndpointErrorResponse": { + "properties": { + "message": { + "type": "string", + "example": "Forbidden - Valid token but missing correct scope\n" + } + } + } + }, + "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 +121,33 @@ } } } + }, + "401": { + "description": "Unauthorized - Invalid token", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden - Valid token but missing correct scope", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:health"] + } + ] } }, "/api/v1/api-server/get-prometheus-exporter-metrics": { @@ -126,8 +171,33 @@ } } } + }, + "401": { + "description": "Unauthorized - Invalid token", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden - Valid token but missing correct scope", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:metrics"] + } + ] } }, "/api/v1/api-server/get-open-api-spec": { @@ -151,8 +221,33 @@ } } } + }, + "401": { + "description": "Unauthorized - Invalid token", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden - Valid token but missing correct scope", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CmdApiServerEndpointErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerTokenAuth": ["read:spec"] + } + ] } } } diff --git a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/.openapi-generator/FILES b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/.openapi-generator/FILES index 9354990b616..e1a27d52906 100644 --- a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/.openapi-generator/FILES +++ b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/.openapi-generator/FILES @@ -21,6 +21,7 @@ src/main/kotlin/org/openapitools/client/infrastructure/ResponseExtensions.kt src/main/kotlin/org/openapitools/client/infrastructure/Serializer.kt src/main/kotlin/org/openapitools/client/infrastructure/URIAdapter.kt src/main/kotlin/org/openapitools/client/infrastructure/UUIDAdapter.kt +src/main/kotlin/org/openapitools/client/models/CmdApiServerEndpointErrorResponse.kt src/main/kotlin/org/openapitools/client/models/HealthCheckResponse.kt src/main/kotlin/org/openapitools/client/models/MemoryUsage.kt src/main/kotlin/org/openapitools/client/models/WatchHealthcheckV1.kt 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 a9770d9c2ef..dbb033f5b69 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 @@ -52,6 +52,7 @@ Class | Method | HTTP request | Description ## Documentation for Models + - [org.openapitools.client.models.CmdApiServerEndpointErrorResponse](docs/CmdApiServerEndpointErrorResponse.md) - [org.openapitools.client.models.HealthCheckResponse](docs/HealthCheckResponse.md) - [org.openapitools.client.models.MemoryUsage](docs/MemoryUsage.md) - [org.openapitools.client.models.WatchHealthcheckV1](docs/WatchHealthcheckV1.md) @@ -60,5 +61,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..9464bd4d053 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 @@ -19,6 +19,7 @@ import java.io.IOException import okhttp3.OkHttpClient import okhttp3.HttpUrl +import org.openapitools.client.models.CmdApiServerEndpointErrorResponse import org.openapitools.client.models.HealthCheckResponse import com.squareup.moshi.Json @@ -108,7 +109,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 +177,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 +244,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/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/models/CmdApiServerEndpointErrorResponse.kt b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/models/CmdApiServerEndpointErrorResponse.kt new file mode 100644 index 00000000000..a1502dda6a1 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/models/CmdApiServerEndpointErrorResponse.kt @@ -0,0 +1,35 @@ +/** + * + * Please note: + * This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit this file manually. + * + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport" +) + +package org.openapitools.client.models + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * + * + * @param message + */ + + +data class CmdApiServerEndpointErrorResponse ( + + @Json(name = "message") + val message: kotlin.String? = null + +) + diff --git a/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/.openapi-generator/FILES b/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/.openapi-generator/FILES index 270cf21ca1a..779704fc76e 100644 --- a/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/.openapi-generator/FILES +++ b/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/.openapi-generator/FILES @@ -1,4 +1,5 @@ README.md +models/cmd_api_server_endpoint_error_response_pb.proto models/health_check_response_pb.proto models/memory_usage_pb.proto models/watch_healthcheck_v1_pb.proto diff --git a/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/models/cmd_api_server_endpoint_error_response_pb.proto b/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/models/cmd_api_server_endpoint_error_response_pb.proto new file mode 100644 index 00000000000..cf9db105765 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/models/cmd_api_server_endpoint_error_response_pb.proto @@ -0,0 +1,19 @@ +/* + Hyperledger Cactus API + + Interact with a Cactus deployment through HTTP. + + The version of the OpenAPI document: 2.0.0 + + Generated by OpenAPI Generator: https://openapi-generator.tech +*/ + +syntax = "proto3"; + +package org.hyperledger.cactus.cmd_api_server; + + +message CmdApiServerEndpointErrorResponsePB { + string message = 418054152; + +} diff --git a/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/services/default_service.proto b/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/services/default_service.proto index e1d50cfdede..5297324f112 100644 --- a/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/services/default_service.proto +++ b/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/services/default_service.proto @@ -13,6 +13,7 @@ syntax = "proto3"; package org.hyperledger.cactus.cmd_api_server; import "google/protobuf/empty.proto"; +import "models/cmd_api_server_endpoint_error_response_pb.proto"; import "models/health_check_response_pb.proto"; service DefaultService { 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 79a7e263419..76b05573857 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 6610e56b536..67b89bca7fc 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 @@ -23,6 +23,19 @@ import type { RequestArgs } from './base'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, BaseAPI, RequiredError } from './base'; +/** + * + * @export + * @interface CmdApiServerEndpointErrorResponse + */ +export interface CmdApiServerEndpointErrorResponse { + /** + * + * @type {string} + * @memberof CmdApiServerEndpointErrorResponse + */ + 'message'?: string; +} /** * * @export @@ -128,6 +141,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 +174,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 +208,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/generated/proto/protoc-gen-ts/models/cmd_api_server_endpoint_error_response_pb.ts b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/models/cmd_api_server_endpoint_error_response_pb.ts new file mode 100644 index 00000000000..5c031ffef49 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/models/cmd_api_server_endpoint_error_response_pb.ts @@ -0,0 +1,75 @@ +/** + * Generated by the protoc-gen-ts. DO NOT EDIT! + * compiler version: 3.19.1 + * source: models/cmd_api_server_endpoint_error_response_pb.proto + * git: https://github.com/thesayyn/protoc-gen-ts */ +import * as pb_1 from "google-protobuf"; +export namespace org.hyperledger.cactus.cmd_api_server { + export class CmdApiServerEndpointErrorResponsePB extends pb_1.Message { + #one_of_decls: number[][] = []; + constructor(data?: any[] | { + message?: string; + }) { + super(); + pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls); + if (!Array.isArray(data) && typeof data == "object") { + if ("message" in data && data.message != undefined) { + this.message = data.message; + } + } + } + get message() { + return pb_1.Message.getFieldWithDefault(this, 418054152, "") as string; + } + set message(value: string) { + pb_1.Message.setField(this, 418054152, value); + } + static fromObject(data: { + message?: string; + }): CmdApiServerEndpointErrorResponsePB { + const message = new CmdApiServerEndpointErrorResponsePB({}); + if (data.message != null) { + message.message = data.message; + } + return message; + } + toObject() { + const data: { + message?: string; + } = {}; + if (this.message != null) { + data.message = this.message; + } + return data; + } + serialize(): Uint8Array; + serialize(w: pb_1.BinaryWriter): void; + serialize(w?: pb_1.BinaryWriter): Uint8Array | void { + const writer = w || new pb_1.BinaryWriter(); + if (this.message.length) + writer.writeString(418054152, this.message); + if (!w) + return writer.getResultBuffer(); + } + static deserialize(bytes: Uint8Array | pb_1.BinaryReader): CmdApiServerEndpointErrorResponsePB { + const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new CmdApiServerEndpointErrorResponsePB(); + while (reader.nextField()) { + if (reader.isEndGroup()) + break; + switch (reader.getFieldNumber()) { + case 418054152: + message.message = reader.readString(); + break; + default: reader.skipField(); + } + } + return message; + } + serializeBinary(): Uint8Array { + return this.serialize(); + } + static deserializeBinary(bytes: Uint8Array): CmdApiServerEndpointErrorResponsePB { + return CmdApiServerEndpointErrorResponsePB.deserialize(bytes); + } + } +} diff --git a/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service.ts b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service.ts index 06d8bf0eb10..f4086197f86 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service.ts @@ -4,7 +4,8 @@ * source: services/default_service.proto * git: https://github.com/thesayyn/protoc-gen-ts */ import * as dependency_1 from "./../google/protobuf/empty"; -import * as dependency_2 from "./../models/health_check_response_pb"; +import * as dependency_2 from "./../models/cmd_api_server_endpoint_error_response_pb"; +import * as dependency_3 from "./../models/health_check_response_pb"; import * as pb_1 from "google-protobuf"; import * as grpc_1 from "@grpc/grpc-js"; export namespace org.hyperledger.cactus.cmd_api_server { @@ -174,8 +175,8 @@ export namespace org.hyperledger.cactus.cmd_api_server { responseStream: false, requestSerialize: (message: dependency_1.google.protobuf.Empty) => Buffer.from(message.serialize()), requestDeserialize: (bytes: Buffer) => dependency_1.google.protobuf.Empty.deserialize(new Uint8Array(bytes)), - responseSerialize: (message: dependency_2.org.hyperledger.cactus.cmd_api_server.HealthCheckResponsePB) => Buffer.from(message.serialize()), - responseDeserialize: (bytes: Buffer) => dependency_2.org.hyperledger.cactus.cmd_api_server.HealthCheckResponsePB.deserialize(new Uint8Array(bytes)) + responseSerialize: (message: dependency_3.org.hyperledger.cactus.cmd_api_server.HealthCheckResponsePB) => Buffer.from(message.serialize()), + responseDeserialize: (bytes: Buffer) => dependency_3.org.hyperledger.cactus.cmd_api_server.HealthCheckResponsePB.deserialize(new Uint8Array(bytes)) }, GetOpenApiSpecV1: { path: "/org.hyperledger.cactus.cmd_api_server.DefaultService/GetOpenApiSpecV1", @@ -197,7 +198,7 @@ export namespace org.hyperledger.cactus.cmd_api_server { } }; [method: string]: grpc_1.UntypedHandleCall; - abstract GetHealthCheckV1(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; + abstract GetHealthCheckV1(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; abstract GetOpenApiSpecV1(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; abstract GetPrometheusMetricsV1(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; } @@ -205,7 +206,7 @@ export namespace org.hyperledger.cactus.cmd_api_server { constructor(address: string, credentials: grpc_1.ChannelCredentials, options?: Partial) { super(address, credentials, options); } - GetHealthCheckV1: GrpcUnaryServiceInterface = (message: dependency_1.google.protobuf.Empty, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback, options?: grpc_1.CallOptions | grpc_1.requestCallback, callback?: grpc_1.requestCallback): grpc_1.ClientUnaryCall => { + GetHealthCheckV1: GrpcUnaryServiceInterface = (message: dependency_1.google.protobuf.Empty, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback, options?: grpc_1.CallOptions | grpc_1.requestCallback, callback?: grpc_1.requestCallback): grpc_1.ClientUnaryCall => { return super.GetHealthCheckV1(message, metadata, options, callback); }; GetOpenApiSpecV1: GrpcUnaryServiceInterface = (message: dependency_1.google.protobuf.Empty, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback, options?: grpc_1.CallOptions | grpc_1.requestCallback, callback?: grpc_1.requestCallback): grpc_1.ClientUnaryCall => { 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/benchmark/run-cmd-api-server-benchmark.ts b/packages/cactus-cmd-api-server/src/test/typescript/benchmark/run-cmd-api-server-benchmark.ts index 2babf8ff130..70bd10444d2 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 2e0d8c7e571..555520c3d66 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 38b636d7c42..2fb27ed2fe6 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 5b44fa205a0..70ae5e0a8d8 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 00000000000..ec264c0b268 --- /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(); + + await expect( + apiClient.getOpenApiSpecV1({ + headers: { Authorization: validBearerToken }, + }), + ).rejects.toMatchObject({ + response: { + status: 403, + statusText: expect.stringContaining("Forbidden"), + }, + }); + }); + + 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(); + + await expect( + apiClient.getOpenApiSpecV1({ + headers: { Authorization: invalidBearerToken }, + }), + ).rejects.toMatchObject({ + response: { + status: 401, + data: expect.stringContaining("Unauthorized"), + }, + }); + }); + + 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(); + + await expect( + apiClient.getHealthCheckV1({ + headers: { Authorization: invalidBearerToken }, + }), + ).rejects.toMatchObject({ + response: { + status: 401, + data: expect.stringContaining("Unauthorized"), + }, + }); + }); + + 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(); + + await expect( + apiClient.getPrometheusMetricsV1({ + headers: { Authorization: invalidBearerToken }, + }), + ).rejects.toMatchObject({ + response: { + status: 401, + data: expect.stringContaining("Unauthorized"), + }, + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 590decc8637..3e367b4193e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9521,7 +9521,7 @@ __metadata: google-protobuf: "npm:3.21.4" grpc-tools: "npm:1.12.4" grpc_tools_node_protoc_ts: "npm:5.3.3" - http-status-codes: "npm:2.1.4" + http-status-codes: "npm:2.1.3" jose: "npm:4.15.5" json-stable-stringify: "npm:1.0.2" lmify: "npm:0.3.0" @@ -32848,6 +32848,13 @@ __metadata: languageName: node linkType: hard +"http-status-codes@npm:2.1.3": + version: 2.1.3 + resolution: "http-status-codes@npm:2.1.3" + checksum: 10/d5025903b41d88d35c1039e6fa4fe4061cce95119f58672d56ed8837bf91cc10c1b3dc47b73288398b55990e7a2c2147ae32feddc896b6863f020b44ef58ee6e + languageName: node + linkType: hard + "http-status-codes@npm:2.1.4": version: 2.1.4 resolution: "http-status-codes@npm:2.1.4"