From 3321ed3b7702f9570f951565d20691f5505f4a9a Mon Sep 17 00:00:00 2001 From: aldousalvarez Date: Wed, 7 Aug 2024 13:57: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 --- .../go/generated/openapi/go-client/README.md | 33 +++- .../openapi/go-client/api/openapi.yaml | 25 +++ .../go/generated/openapi/go-client/client.go | 12 ++ .../openapi/go-client/configuration.go | 3 + .../go/generated/openapi/go-client/go.mod | 1 + .../go/generated/openapi/go-client/go.sum | 2 + .../src/main/json/openapi.json | 42 ++++- .../src/main/json/openapi.tpl.json | 42 ++++- .../generated/openapi/kotlin-client/README.md | 13 +- .../openapitools/client/apis/DefaultApi.kt | 6 +- .../client/infrastructure/ApiClient.kt | 10 ++ .../generated/openapi/typescript-axios/api.ts | 12 ++ ...get-open-api-spec-v1-oauth2-scopes.test.ts | 165 ++++++++++++++++++ 13 files changed, 355 insertions(+), 11 deletions(-) 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 ba8f8c9d1e3..273504d9af5 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 @@ -15,6 +15,7 @@ Install the following dependencies: ```shell go get github.com/stretchr/testify/assert +go get golang.org/x/oauth2 go get golang.org/x/net/context ``` @@ -91,7 +92,37 @@ Class | Method | HTTP request | Description ## Documentation For Authorization -Endpoints do not require authorization. + +Authentication schemes defined for the API: +### OAuth2 + + +- **Type**: OAuth +- **Flow**: accessCode +- **Authorization URL**: https://example.com/oauth/authorize +- **Scopes**: + - **read:health**: Read health information + - **read:metrics**: Read metrics information + - **read:spec**: Read OpenAPI specification + +Example + +```golang +auth := context.WithValue(context.Background(), sw.ContextAccessToken, "ACCESSTOKENSTRING") +r, err := client.Service.Operation(auth, args) +``` + +Or via OAuth2 module to automatically refresh tokens and perform user authentication. + +```golang +import "golang.org/x/oauth2" + +/* Perform OAuth2 round trip request and obtain a token */ + +tokenSource := oauth2cfg.TokenSource(createContext(httpClient), &token) +auth := context.WithValue(oauth2.NoContext, sw.ContextOAuth2, tokenSource) +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 eb4edd3e058..1f1cc6de97b 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.3 servers: - url: / +security: +- OAuth2: + - read:health + - read:metrics + - read:spec paths: /api/v1/api-server/healthcheck: get: @@ -21,6 +26,9 @@ paths: schema: $ref: '#/components/schemas/HealthCheckResponse' description: OK + security: + - OAuth2: + - read:health summary: Can be used to verify liveness of an API server instance x-hyperledger-cacti: http: @@ -37,6 +45,9 @@ paths: schema: $ref: '#/components/schemas/PrometheusExporterMetricsResponse' description: OK + security: + - OAuth2: + - read:metrics summary: Get the Prometheus Metrics x-hyperledger-cacti: http: @@ -54,6 +65,9 @@ paths: schema: $ref: '#/components/schemas/GetOpenApiSpecV1EndpointResponse' description: OK + security: + - OAuth2: + - read:spec x-hyperledger-cacti: http: verbLowerCase: get @@ -127,3 +141,14 @@ components: GetOpenApiSpecV1EndpointResponse: nullable: false type: string + securitySchemes: + OAuth2: + flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + scopes: + read:health: Read health information + read:metrics: Read metrics information + read:spec: Read OpenAPI specification + tokenUrl: https://example.com/oauth/token + type: oauth2 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 0b8adb637e5..779dbcbe807 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 @@ -32,6 +32,7 @@ import ( "time" "unicode/utf8" + "golang.org/x/oauth2" ) var ( @@ -410,6 +411,17 @@ func (c *APIClient) prepareRequest( // Walk through any authentication. + // OAuth2 authentication + if tok, ok := ctx.Value(ContextOAuth2).(oauth2.TokenSource); ok { + // We were able to grab an oauth2 token from the context + var latestToken *oauth2.Token + if latestToken, err = tok.Token(); err != nil { + return nil, err + } + + latestToken.SetAuthHeader(localVarRequest) + } + } 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 9d761b0c7bc..3400d795ba7 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 ( + // ContextOAuth2 takes an oauth2.TokenSource as authentication for the request. + ContextOAuth2 = contextKey("token") + // 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/go.mod b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/go.mod index cf6f7e9b4ab..18881d23d4a 100644 --- a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/go.mod +++ b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/go.mod @@ -3,4 +3,5 @@ module github.com/hyperledger/cactus-cmd-api-server/src/main/go/generated/openap go 1.18 require ( + golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558 ) diff --git a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/go.sum b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/go.sum index c966c8ddfd0..734252e6815 100644 --- a/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/go.sum +++ b/packages/cactus-cmd-api-server/src/main/go/generated/openapi/go-client/go.sum @@ -4,6 +4,8 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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 8ca9fbf2202..d91b1560288 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,29 @@ "type": "string", "nullable": false } + }, + "securitySchemes": { + "OAuth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "read:health": "Read health information", + "read:metrics": "Read metrics information", + "read:spec": "Read OpenAPI specification" + } + } + } + } } }, + "security": [ + { + "OAuth2": ["read:health", "read:metrics", "read:spec"] + } + ], "paths": { "/api/v1/api-server/healthcheck": { "get": { @@ -102,7 +123,12 @@ } } } - } + }, + "security": [ + { + "OAuth2": ["read:health"] + } + ] } }, "/api/v1/api-server/get-prometheus-exporter-metrics": { @@ -127,7 +153,12 @@ } } } - } + }, + "security": [ + { + "OAuth2": ["read:metrics"] + } + ] } }, "/api/v1/api-server/get-open-api-spec": { @@ -152,7 +183,12 @@ } } } - } + }, + "security": [ + { + "OAuth2": ["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 8ca9fbf2202..d91b1560288 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,29 @@ "type": "string", "nullable": false } + }, + "securitySchemes": { + "OAuth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "read:health": "Read health information", + "read:metrics": "Read metrics information", + "read:spec": "Read OpenAPI specification" + } + } + } + } } }, + "security": [ + { + "OAuth2": ["read:health", "read:metrics", "read:spec"] + } + ], "paths": { "/api/v1/api-server/healthcheck": { "get": { @@ -102,7 +123,12 @@ } } } - } + }, + "security": [ + { + "OAuth2": ["read:health"] + } + ] } }, "/api/v1/api-server/get-prometheus-exporter-metrics": { @@ -127,7 +153,12 @@ } } } - } + }, + "security": [ + { + "OAuth2": ["read:metrics"] + } + ] } }, "/api/v1/api-server/get-open-api-spec": { @@ -152,7 +183,12 @@ } } } - } + }, + "security": [ + { + "OAuth2": ["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 8321b31aab0..7ec8d27e9cb 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,16 @@ Class | Method | HTTP request | Description ## Documentation for Authorization -Endpoints do not require authorization. + +Authentication schemes defined for the API: + +### OAuth2 + +- **Type**: OAuth +- **Flow**: accessCode +- **Authorization URL**: https://example.com/oauth/authorize +- **Scopes**: + - read:health: Read health information + - read:metrics: Read metrics information + - read:spec: Read OpenAPI specification 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..79a51064818 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/generated/openapi/typescript-axios/api.ts b/packages/cactus-cmd-api-server/src/main/typescript/generated/openapi/typescript-axios/api.ts index c31c56ec0eb..24e646c87af 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 OAuth2 required + // oauth required + await setOAuthToObject(localVarHeaderParameter, "OAuth2", ["read:health"], configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -157,6 +161,10 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + // authentication OAuth2 required + // oauth required + await setOAuthToObject(localVarHeaderParameter, "OAuth2", ["read:spec"], configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -187,6 +195,10 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + // authentication OAuth2 required + // oauth required + await setOAuthToObject(localVarHeaderParameter, "OAuth2", ["read:metrics"], configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter); 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..98327afadda --- /dev/null +++ b/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-oauth2-scopes.test.ts @@ -0,0 +1,165 @@ +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 = "TRACE"; + let apiServer: ApiServer; + let apiClient: ApiServerApiClient; + + afterAll(async () => await apiServer.shutdown()); + + beforeAll(async () => { + const jwtKeyPair = await generateKeyPair("RS256", { modulusLength: 4096 }); + const jwtPublicKey = await exportSPKI(jwtKeyPair.publicKey); + const expressJwtOptions: ExpressJwtOptions & IJoseFittingJwtParams = { + 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]); + }); + }); + }); +});