diff --git a/.changeset/security-scopes-defined-rule.md b/.changeset/security-scopes-defined-rule.md new file mode 100644 index 0000000000..801c8611e1 --- /dev/null +++ b/.changeset/security-scopes-defined-rule.md @@ -0,0 +1,7 @@ +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Added a new built-in rule `security-scopes-defined` that requires every scope used in a security requirement to be defined in the corresponding OAuth2 security scheme. +The rule supports OpenAPI 2.0/3.x and AsyncAPI 2.6/3.0, suggests the closest defined scope for typos, and has an opt-in `requireScopes` option that requires OAuth2 security requirements to list at least one scope. diff --git a/docs/@v2/rules/built-in-rules.md b/docs/@v2/rules/built-in-rules.md index ab3608570a..5e52a7a058 100644 --- a/docs/@v2/rules/built-in-rules.md +++ b/docs/@v2/rules/built-in-rules.md @@ -35,6 +35,7 @@ The rules list is split into sections. - [no-unused-components](./oas/no-unused-components.md): All components must be used - [nullable-type-sibling](./oas/nullable-type-sibling.md): `nullable` must be used with a `type` - [security-defined](./oas/security-defined.md): Security rules must be defined, either globally or per-operation +- [security-scopes-defined](./common/security-scopes-defined.md): Scopes used in security requirements must be defined in the corresponding OAuth2 security scheme - [struct](./common/struct.md): Conform to the declared OpenAPI specification version - [spec-components-invalid-map-name](./oas/spec-components-invalid-map-name.md): Use only alphanumeric and basic punctuation as key names in the components section - [spec-querystring-parameters](./oas/spec-querystring-parameters.md): Enforce valid use of `in: querystring` (OpenAPI 3.2): at most one per path/operation, and not mixed with `in: query` diff --git a/docs/@v2/rules/common/security-scopes-defined.md b/docs/@v2/rules/common/security-scopes-defined.md new file mode 100644 index 0000000000..9d7b3fb345 --- /dev/null +++ b/docs/@v2/rules/common/security-scopes-defined.md @@ -0,0 +1,116 @@ +--- +slug: /docs/cli/rules/common/security-scopes-defined +--- + +# security-scopes-defined + +Requires that every scope used in a security requirement is defined in the corresponding OAuth2 security scheme. + +| OAS | Compatibility | +| --- | ------------- | +| 2.0 | ✅ | +| 3.0 | ✅ | +| 3.1 | ✅ | +| 3.2 | ✅ | + +| AsyncAPI | Compatibility | +| -------- | ------------- | +| 2.6 | ✅ | +| 3.0 | ✅ | + +The rule checks security schemes of type `oauth2`, where the set of valid scopes is declared in the API description: + +- **OpenAPI 3.x and AsyncAPI 2.6**: scopes used in security requirements must be declared in the `scopes` of at least one of the scheme's `flows`. +- **OpenAPI 2.0**: scopes used in security requirements must be declared in the `scopes` of the scheme. +- **AsyncAPI 3.0**: scopes listed in the `scopes` of a security scheme must be declared in the `availableScopes` of at least one of the scheme's `flows`. + +Other scheme types are skipped: `openIdConnect` scopes are defined behind the discovery URL and can't be checked statically. +For the remaining types OpenAPI 3.1 and later allow arbitrary role names. +Requirements that reference undefined security schemes are skipped as well — those are reported by the [security-defined](../oas/security-defined.md) rule. + +## API design principles + +A scope that is used in a security requirement but not declared in the security scheme is almost always a typo or a leftover from a renamed scope. +Clients generated or configured from such a description request permissions that don't exist, and fail at authorization time. +This rule catches the mismatch early and suggests the closest declared scope. + +## Configuration + +| Option | Type | Description | +| ------------- | ------- | ----------------------------------------------------------------------------------------- | +| severity | string | Possible values: `off`, `warn`, `error`. Default `warn` (in `recommended` configuration). | +| requireScopes | boolean | Requires every `oauth2` security requirement to list at least one scope. Default `false`. | + +An example configuration: + +```yaml +rules: + security-scopes-defined: + severity: error + requireScopes: true +``` + +## Examples + +Given this configuration: + +```yaml +rules: + security-scopes-defined: error +``` + +Example of an **incorrect** security requirement — the `read:pet` scope is not defined in the scheme: + +```yaml +paths: + /pets: + get: + security: + - petstore_auth: + - read:pet +components: + securitySchemes: + petstore_auth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/authorize + tokenUrl: https://example.com/token + scopes: + read:pets: Read pets + write:pets: Write pets +``` + +Example of a **correct** security requirement: + +```yaml +paths: + /pets: + get: + security: + - petstore_auth: + - read:pets +components: + securitySchemes: + petstore_auth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/authorize + tokenUrl: https://example.com/token + scopes: + read:pets: Read pets + write:pets: Write pets +``` + +## Related rules + +- [security-defined](../oas/security-defined.md) +- [no-unused-components](../oas/no-unused-components.md) +- [configurable rules](../configurable-rules.md) + +## Resources + +- [Rule source for OpenAPI and AsyncAPI 2.6](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/common/security-scopes-defined.ts) +- [Rule source for AsyncAPI 3.0](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/async3/security-scopes-defined.ts) +- [Security scheme docs](https://redocly.com/docs/openapi-visual-reference/security-schemes/) diff --git a/docs/@v2/rules/recommended.md b/docs/@v2/rules/recommended.md index 2c0ef91e25..dd813bda5e 100644 --- a/docs/@v2/rules/recommended.md +++ b/docs/@v2/rules/recommended.md @@ -60,6 +60,7 @@ Warnings: - [operation-4xx-response](./oas/operation-4xx-response.md) - [operation-operationId](./oas/operation-operationId.md) - [requestBody-replacements-unique](./arazzo/requestBody-replacements-unique.md) +- [security-scopes-defined](./common/security-scopes-defined.md) - [spec-discriminator-defaultMapping](./oas/spec-discriminator-defaultMapping.md) - [step-onFailure-unique](./arazzo/step-onFailure-unique.md) - [step-onSuccess-unique](./arazzo/step-onSuccess-unique.md) diff --git a/docs/@v2/rules/ruleset-templates.md b/docs/@v2/rules/ruleset-templates.md index ac3926d0ed..c46e24c234 100644 --- a/docs/@v2/rules/ruleset-templates.md +++ b/docs/@v2/rules/ruleset-templates.md @@ -222,6 +222,7 @@ rules: path-parameters-defined: error path-params-defined: error security-defined: error + security-scopes-defined: warn spec-components-invalid-map-name: error spec-discriminator-defaultMapping: warn spec-example-values: error @@ -268,6 +269,7 @@ rules: path-parameters-defined: error path-params-defined: error security-defined: error + security-scopes-defined: warn spec-components-invalid-map-name: error struct: error tag-description: warn @@ -310,6 +312,7 @@ rules: path-parameters-defined: error path-params-defined: error security-defined: error + security-scopes-defined: warn spec-components-invalid-map-name: error struct: error tag-description: warn @@ -343,6 +346,7 @@ rules: path-parameters-defined: error path-params-defined: error security-defined: error + security-scopes-defined: warn struct: error tag-description: warn ``` @@ -358,6 +362,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: error operation-operationId: warn + security-scopes-defined: warn struct: error tag-description: warn ``` @@ -373,6 +378,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: error operation-operationId: warn + security-scopes-defined: warn struct: error tag-description: warn ``` diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index ba5ae64193..954aad8145 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -101,6 +101,7 @@ - page: rules/common/no-schema-type-mismatch.md - page: rules/common/no-required-schema-properties-undefined.md - page: rules/common/no-mixed-number-range-constraints.md + - page: rules/common/security-scopes-defined.md - separator: OpenAPI - page: rules/oas/array-parameter-serialization.md - page: rules/oas/boolean-parameter-prefixes.md diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap index 5e56a54ee5..7606619204 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap @@ -67,6 +67,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -83,6 +84,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -133,6 +135,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-strict-refs": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -194,6 +197,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-example-values": "off", "spec-strict-refs": "off", @@ -254,6 +258,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-example-values": "off", "spec-strict-refs": "off", @@ -313,6 +318,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-discriminator-defaultMapping": "warn", "spec-example-values": "error", @@ -484,6 +490,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -500,6 +507,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -550,6 +558,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-strict-refs": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -611,6 +620,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-example-values": "off", "spec-strict-refs": "off", @@ -671,6 +681,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-example-values": "off", "spec-strict-refs": "off", @@ -730,6 +741,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-discriminator-defaultMapping": "warn", "spec-example-values": "error", diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index 977ea0973f..20a957169c 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -190,6 +190,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -206,6 +207,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -259,6 +261,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-strict-refs": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -322,6 +325,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-example-values": "off", "spec-strict-refs": "off", @@ -384,6 +388,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-example-values": "off", "spec-strict-refs": "off", @@ -445,6 +450,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-discriminator-defaultMapping": "off", "spec-example-values": "off", @@ -544,6 +550,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -560,6 +567,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -611,6 +619,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-strict-refs": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -672,6 +681,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-example-values": "off", "spec-strict-refs": "off", @@ -732,6 +742,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-example-values": "off", "spec-strict-refs": "off", @@ -791,6 +802,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-discriminator-defaultMapping": "warn", "spec-example-values": "error", @@ -903,6 +915,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -919,6 +932,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -970,6 +984,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-strict-refs": "error", "tag-description": "warn", "tags-alphabetical": "off", @@ -1031,6 +1046,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-example-values": "off", "spec-strict-refs": "error", @@ -1091,6 +1107,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-example-values": "off", "spec-strict-refs": "error", @@ -1150,6 +1167,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-discriminator-defaultMapping": "off", "spec-example-values": "off", @@ -1346,6 +1364,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1362,6 +1381,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1415,6 +1435,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-strict-refs": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -1478,6 +1499,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-example-values": "off", "spec-strict-refs": "off", @@ -1540,6 +1562,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-example-values": "off", "spec-strict-refs": "off", @@ -1601,6 +1624,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-discriminator-defaultMapping": "off", "spec-example-values": "off", @@ -1700,6 +1724,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1716,6 +1741,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1767,6 +1793,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-strict-refs": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -1828,6 +1855,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-example-values": "off", "spec-strict-refs": "off", @@ -1888,6 +1916,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-example-values": "off", "spec-strict-refs": "off", @@ -1947,6 +1976,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "error", + "security-scopes-defined": "warn", "spec-components-invalid-map-name": "error", "spec-discriminator-defaultMapping": "warn", "spec-example-values": "error", @@ -2059,6 +2089,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -2075,6 +2106,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -2126,6 +2158,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-strict-refs": "error", "tag-description": "warn", "tags-alphabetical": "off", @@ -2187,6 +2220,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-example-values": "off", "spec-strict-refs": "error", @@ -2247,6 +2281,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-example-values": "off", "spec-strict-refs": "error", @@ -2306,6 +2341,7 @@ describe('loadConfig', () => { "response-mime-type": "off", "scalar-property-missing-example": "off", "security-defined": "warn", + "security-scopes-defined": "off", "spec-components-invalid-map-name": "warn", "spec-discriminator-defaultMapping": "off", "spec-example-values": "off", diff --git a/packages/core/src/config/all.ts b/packages/core/src/config/all.ts index 8f87ed9036..49e54dd65d 100644 --- a/packages/core/src/config/all.ts +++ b/packages/core/src/config/all.ts @@ -49,6 +49,7 @@ const all: RawGovernanceConfig<'built-in'> = { }, 'response-contains-property': 'error', 'security-defined': 'error', + 'security-scopes-defined': 'error', 'spec-strict-refs': 'error', 'scalar-property-missing-example': 'error', 'tag-description': 'error', @@ -111,6 +112,7 @@ const all: RawGovernanceConfig<'built-in'> = { }, 'response-contains-property': 'error', 'security-defined': 'error', + 'security-scopes-defined': 'error', 'spec-strict-refs': 'error', 'scalar-property-missing-example': 'error', 'spec-components-invalid-map-name': 'error', @@ -175,6 +177,7 @@ const all: RawGovernanceConfig<'built-in'> = { }, 'response-contains-property': 'error', 'security-defined': 'error', + 'security-scopes-defined': 'error', 'spec-strict-refs': 'error', 'scalar-property-missing-example': 'error', 'spec-components-invalid-map-name': 'error', @@ -238,6 +241,7 @@ const all: RawGovernanceConfig<'built-in'> = { }, 'response-contains-property': 'error', 'security-defined': 'error', + 'security-scopes-defined': 'error', 'spec-strict-refs': 'error', 'scalar-property-missing-example': 'error', 'spec-components-invalid-map-name': 'error', @@ -256,6 +260,7 @@ const all: RawGovernanceConfig<'built-in'> = { 'info-license-strict': 'error', 'no-channel-trailing-slash': 'error', 'operation-operationId': 'error', + 'security-scopes-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'error', 'no-duplicated-tag-names': 'error', @@ -270,6 +275,7 @@ const all: RawGovernanceConfig<'built-in'> = { 'info-license-strict': 'error', 'no-channel-trailing-slash': 'error', 'operation-operationId': 'error', + 'security-scopes-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'error', 'no-duplicated-tag-names': 'error', diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts index a825d28faf..7c2d28e0f8 100644 --- a/packages/core/src/config/minimal.ts +++ b/packages/core/src/config/minimal.ts @@ -45,6 +45,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'warn', + 'security-scopes-defined': 'off', 'spec-strict-refs': 'off', 'tag-description': 'warn', 'tags-alphabetical': 'off', @@ -104,6 +105,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'warn', + 'security-scopes-defined': 'off', 'spec-components-invalid-map-name': 'warn', 'spec-example-values': 'off', 'spec-strict-refs': 'off', @@ -162,6 +164,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'warn', + 'security-scopes-defined': 'off', 'spec-components-invalid-map-name': 'warn', 'spec-example-values': 'off', 'spec-strict-refs': 'off', @@ -219,6 +222,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'warn', + 'security-scopes-defined': 'off', 'spec-components-invalid-map-name': 'warn', 'spec-discriminator-defaultMapping': 'off', 'spec-example-values': 'off', @@ -240,6 +244,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'warn', 'operation-operationId': 'warn', + 'security-scopes-defined': 'off', 'tag-description': 'warn', 'tags-alphabetical': 'off', }, @@ -254,6 +259,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'warn', 'operation-operationId': 'warn', + 'security-scopes-defined': 'off', 'tag-description': 'warn', 'tags-alphabetical': 'off', }, diff --git a/packages/core/src/config/recommended-strict.ts b/packages/core/src/config/recommended-strict.ts index 9293fd0433..1e484aeba6 100644 --- a/packages/core/src/config/recommended-strict.ts +++ b/packages/core/src/config/recommended-strict.ts @@ -45,6 +45,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'error', + 'security-scopes-defined': 'error', 'spec-strict-refs': 'off', 'tag-description': 'error', 'tags-alphabetical': 'off', @@ -104,6 +105,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'error', + 'security-scopes-defined': 'error', 'spec-components-invalid-map-name': 'error', 'spec-example-values': 'off', 'spec-strict-refs': 'off', @@ -162,6 +164,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'error', + 'security-scopes-defined': 'error', 'spec-components-invalid-map-name': 'error', 'spec-example-values': 'off', 'spec-strict-refs': 'off', @@ -219,6 +222,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'error', + 'security-scopes-defined': 'error', 'spec-components-invalid-map-name': 'error', 'spec-discriminator-defaultMapping': 'error', 'spec-example-values': 'error', @@ -240,6 +244,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'error', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'error', + 'security-scopes-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'off', }, @@ -254,6 +259,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'error', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'error', + 'security-scopes-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'off', }, diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts index 077945f181..9877b5b6a7 100644 --- a/packages/core/src/config/recommended.ts +++ b/packages/core/src/config/recommended.ts @@ -45,6 +45,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'error', + 'security-scopes-defined': 'warn', 'spec-strict-refs': 'off', 'tag-description': 'warn', 'tags-alphabetical': 'off', @@ -104,6 +105,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'error', + 'security-scopes-defined': 'warn', 'spec-components-invalid-map-name': 'error', 'spec-example-values': 'off', 'spec-strict-refs': 'off', @@ -162,6 +164,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'error', + 'security-scopes-defined': 'warn', 'spec-components-invalid-map-name': 'error', 'spec-example-values': 'off', 'spec-strict-refs': 'off', @@ -219,6 +222,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'error', + 'security-scopes-defined': 'warn', 'spec-components-invalid-map-name': 'error', 'spec-discriminator-defaultMapping': 'warn', 'spec-example-values': 'error', @@ -240,6 +244,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'warn', + 'security-scopes-defined': 'warn', 'tag-description': 'warn', 'tags-alphabetical': 'off', }, @@ -254,6 +259,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'warn', + 'security-scopes-defined': 'warn', 'tag-description': 'warn', 'tags-alphabetical': 'off', }, diff --git a/packages/core/src/config/spec.ts b/packages/core/src/config/spec.ts index 689101c2d9..3b3e69c404 100644 --- a/packages/core/src/config/spec.ts +++ b/packages/core/src/config/spec.ts @@ -45,6 +45,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'off', + 'security-scopes-defined': 'off', 'spec-strict-refs': 'error', 'tag-description': 'off', 'tags-alphabetical': 'off', @@ -104,6 +105,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'off', + 'security-scopes-defined': 'off', 'spec-components-invalid-map-name': 'error', 'spec-example-values': 'off', 'spec-strict-refs': 'error', @@ -162,6 +164,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'off', + 'security-scopes-defined': 'off', 'spec-components-invalid-map-name': 'error', 'spec-example-values': 'off', 'spec-strict-refs': 'error', @@ -219,6 +222,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'response-mime-type': 'off', 'scalar-property-missing-example': 'off', 'security-defined': 'off', + 'security-scopes-defined': 'off', 'spec-components-invalid-map-name': 'error', 'spec-discriminator-defaultMapping': 'error', 'spec-example-values': 'error', @@ -240,6 +244,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'off', 'no-schema-type-mismatch': 'off', 'operation-operationId': 'off', + 'security-scopes-defined': 'off', 'tag-description': 'off', 'tags-alphabetical': 'off', }, @@ -254,6 +259,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'off', 'no-schema-type-mismatch': 'off', 'operation-operationId': 'off', + 'security-scopes-defined': 'off', 'tag-description': 'off', 'tags-alphabetical': 'off', }, diff --git a/packages/core/src/rules/async2/index.ts b/packages/core/src/rules/async2/index.ts index 73aec1bb2c..70364765b9 100644 --- a/packages/core/src/rules/async2/index.ts +++ b/packages/core/src/rules/async2/index.ts @@ -10,6 +10,7 @@ import { NoRequiredSchemaPropertiesUndefined } from '../common/no-required-schem import { NoSchemaTypeMismatch } from '../common/no-schema-type-mismatch.js'; import { NoUnresolvedRefs } from '../common/no-unresolved-refs.js'; import { OperationOperationId } from '../common/operation-operationId.js'; +import { SecurityScopesDefined } from '../common/security-scopes-defined.js'; import { Struct } from '../common/struct.js'; import { TagDescription } from '../common/tag-description.js'; import { TagsAlphabetical } from '../common/tags-alphabetical.js'; @@ -32,6 +33,7 @@ export const rules: Async2RuleSet<'built-in'> = { 'no-enum-type-mismatch': NoEnumTypeMismatch as Async2Rule, 'no-mixed-number-range-constraints': NoMixedNumberRangeConstraints as Async2Rule, 'no-schema-type-mismatch': NoSchemaTypeMismatch as Async2Rule, + 'security-scopes-defined': SecurityScopesDefined as Async2Rule, }; export const preprocessors = {}; diff --git a/packages/core/src/rules/async3/__tests__/security-scopes-defined.test.ts b/packages/core/src/rules/async3/__tests__/security-scopes-defined.test.ts new file mode 100644 index 0000000000..f77c5d579a --- /dev/null +++ b/packages/core/src/rules/async3/__tests__/security-scopes-defined.test.ts @@ -0,0 +1,269 @@ +import { outdent } from 'outdent'; + +import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { lintDocument } from '../../../lint.js'; +import { BaseResolver } from '../../../resolve.js'; + +describe('Async3 security-scopes-defined', () => { + it('should report scopes that are not listed in the available scopes of the flows', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Example API + version: 1.0.0 + servers: + production: + host: broker.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/broker_auth' + channels: {} + components: + securitySchemes: + broker_auth: + type: oauth2 + scopes: + - write:events + flows: + clientCredentials: + tokenUrl: https://example.com/token + availableScopes: + read:events: Read events + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'security-scopes-defined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/components/securitySchemes/broker_auth/scopes/0", + "reportOnKey": false, + "source": "asyncapi.yaml", + }, + ], + "message": "The "write:events" scope is not defined in the available scopes of the security scheme flows.", + "reference": "https://redocly.com/docs/cli/rules/common/security-scopes-defined", + "ruleId": "security-scopes-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when the scheme scopes are listed in the available scopes', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Example API + version: 1.0.0 + servers: + production: + host: broker.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/broker_auth' + channels: {} + components: + securitySchemes: + broker_auth: + type: oauth2 + scopes: + - read:events + flows: + clientCredentials: + tokenUrl: https://example.com/token + availableScopes: + read:events: Read events + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'security-scopes-defined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should resolve $ref-ed flows when collecting available scopes', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Example API + version: 1.0.0 + servers: + production: + host: broker.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/shared_flows_auth' + - $ref: '#/components/securitySchemes/shared_flow_auth' + channels: {} + components: + securitySchemes: + base_auth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/token + availableScopes: + read:events: Read events + shared_flows_auth: + type: oauth2 + scopes: + - read:events + flows: + $ref: '#/components/securitySchemes/base_auth/flows' + shared_flow_auth: + type: oauth2 + scopes: + - read:events + - missing:scope + flows: + clientCredentials: + $ref: '#/components/securitySchemes/base_auth/flows/clientCredentials' + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'security-scopes-defined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/components/securitySchemes/shared_flow_auth/scopes/1", + "reportOnKey": false, + "source": "asyncapi.yaml", + }, + ], + "message": "The "missing:scope" scope is not defined in the available scopes of the security scheme flows.", + "reference": "https://redocly.com/docs/cli/rules/common/security-scopes-defined", + "ruleId": "security-scopes-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report oauth2 schemes that are not referenced by any security list', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Example API + version: 1.0.0 + servers: + production: + host: broker.example.com + protocol: kafka + channels: {} + components: + securitySchemes: + unused_without_scopes: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/token + availableScopes: + read:events: Read events + unused_with_unknown_scope: + type: oauth2 + scopes: + - write:events + flows: + clientCredentials: + tokenUrl: https://example.com/token + availableScopes: + read:events: Read events + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-scopes-defined': { severity: 'error', requireScopes: true } }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report oauth2 schemes without scopes when requireScopes is set', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Example API + version: 1.0.0 + servers: + production: + host: broker.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/broker_auth' + channels: {} + components: + securitySchemes: + broker_auth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/token + availableScopes: + read:events: Read events + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-scopes-defined': { severity: 'error', requireScopes: true } }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/components/securitySchemes/broker_auth", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "The security scheme must list at least one scope.", + "reference": "https://redocly.com/docs/cli/rules/common/security-scopes-defined", + "ruleId": "security-scopes-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/rules/async3/index.ts b/packages/core/src/rules/async3/index.ts index 0623b94b40..0e477ee549 100644 --- a/packages/core/src/rules/async3/index.ts +++ b/packages/core/src/rules/async3/index.ts @@ -15,6 +15,7 @@ import { TagDescription } from '../common/tag-description.js'; import { TagsAlphabetical } from '../common/tags-alphabetical.js'; import { ChannelsKebabCase } from './channels-kebab-case.js'; import { NoChannelTrailingSlash } from './no-channel-trailing-slash.js'; +import { SecurityScopesDefined } from './security-scopes-defined.js'; export const rules: Async3RuleSet<'built-in'> = { struct: Struct as Async3Rule, @@ -32,6 +33,7 @@ export const rules: Async3RuleSet<'built-in'> = { 'no-enum-type-mismatch': NoEnumTypeMismatch as Async3Rule, 'no-mixed-number-range-constraints': NoMixedNumberRangeConstraints as Async3Rule, 'no-schema-type-mismatch': NoSchemaTypeMismatch as Async3Rule, + 'security-scopes-defined': SecurityScopesDefined, }; export const preprocessors = {}; diff --git a/packages/core/src/rules/async3/security-scopes-defined.ts b/packages/core/src/rules/async3/security-scopes-defined.ts new file mode 100644 index 0000000000..4448d007c1 --- /dev/null +++ b/packages/core/src/rules/async3/security-scopes-defined.ts @@ -0,0 +1,92 @@ +import { isRef, type Location } from '../../ref-utils.js'; +import type { Async3OAuth2Flow, Async3SecurityScheme } from '../../typings/asyncapi3.js'; +import type { Referenced } from '../../typings/openapi.js'; +import type { Async3Rule } from '../../visitors.js'; +import type { ResolveFn, UserContext } from '../../walk.js'; +import { getSuggest } from '../utils.js'; + +// The `flows` object and each flow can be behind a `$ref`, so they are +// resolved relative to the file that contains them. +function getAvailableScopes( + scheme: Async3SecurityScheme, + resolve: ResolveFn, + schemeLocation: Location +): string[] { + if (!scheme.flows) return []; + + const { node: flows, location: flowsLocation } = isRef(scheme.flows) + ? resolve>>( + scheme.flows, + schemeLocation.source.absoluteRef + ) + : { node: scheme.flows, location: schemeLocation }; + const flowsSource = (flowsLocation ?? schemeLocation).source.absoluteRef; + + const scopeNames = Object.values(flows ?? {}).flatMap((flow) => { + const resolvedFlow = isRef(flow) ? resolve(flow, flowsSource).node : flow; + return Object.keys(resolvedFlow?.availableScopes || {}); + }); + return [...new Set(scopeNames)]; +} + +export const SecurityScopesDefined: Async3Rule = (opts: { requireScopes?: boolean }) => { + // In AsyncAPI 3 a security requirement is a reference to the scheme itself, + // so schemes are collected from `security` lists and unused ones are skipped. + const usedSchemes = new Map< + string, + { scheme: Async3SecurityScheme; location: Location; availableScopes: string[] } + >(); + + return { + SecuritySchemeList( + schemeList: Referenced[], + { resolve, location }: UserContext + ) { + for (let itemIndex = 0; itemIndex < schemeList.length; itemIndex++) { + const item = schemeList[itemIndex]; + const resolved = isRef(item) + ? resolve(item) + : { node: item, location: location.child([itemIndex]) }; + + if (!resolved.node || !resolved.location) continue; + usedSchemes.set(resolved.location.absolutePointer, { + scheme: resolved.node, + location: resolved.location, + availableScopes: + resolved.node.type === 'oauth2' + ? getAvailableScopes(resolved.node, resolve, resolved.location) + : [], + }); + } + }, + Root: { + leave(_root: unknown, { report }: UserContext) { + for (const { scheme, location, availableScopes } of usedSchemes.values()) { + if (scheme.type !== 'oauth2') continue; + + if (opts.requireScopes && !scheme.scopes?.length) { + report({ + message: `The security scheme must list at least one scope.`, + location: location.key(), + reference: 'https://redocly.com/docs/cli/rules/common/security-scopes-defined', + }); + continue; + } + + const usedScopes = scheme.scopes || []; + for (let scopeIndex = 0; scopeIndex < usedScopes.length; scopeIndex++) { + const scope = usedScopes[scopeIndex]; + if (!availableScopes.includes(scope)) { + report({ + message: `The "${scope}" scope is not defined in the available scopes of the security scheme flows.`, + location: location.child(['scopes', scopeIndex]), + suggest: getSuggest(scope, availableScopes), + reference: 'https://redocly.com/docs/cli/rules/common/security-scopes-defined', + }); + } + } + } + }, + }, + }; +}; diff --git a/packages/core/src/rules/common/__tests__/security-scopes-defined.test.ts b/packages/core/src/rules/common/__tests__/security-scopes-defined.test.ts new file mode 100644 index 0000000000..e8460f934f --- /dev/null +++ b/packages/core/src/rules/common/__tests__/security-scopes-defined.test.ts @@ -0,0 +1,353 @@ +import { outdent } from 'outdent'; + +import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { lintDocument } from '../../../lint.js'; +import { BaseResolver } from '../../../resolve.js'; + +describe('Oas3 security-scopes-defined', () => { + it('should report scopes that are not defined in the security scheme flows', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pets: + get: + security: + - petstore_auth: + - read:pet + components: + securitySchemes: + petstore_auth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/authorize + tokenUrl: https://example.com/token + scopes: + read:pets: Read pets + write:pets: Write pets + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'security-scopes-defined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/paths/~1pets/get/security/0/petstore_auth/0", + "reportOnKey": false, + "source": "foobar.yaml", + }, + ], + "message": "The "read:pet" scope is not defined in the "petstore_auth" security scheme.", + "reference": "https://redocly.com/docs/cli/rules/common/security-scopes-defined", + "ruleId": "security-scopes-defined", + "severity": "error", + "suggest": [ + "read:pets", + ], + }, + ] + `); + }); + + it('should not report when all scopes are defined across the scheme flows', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + security: + - petstore_auth: + - read:pets + - admin + paths: {} + components: + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://example.com/authorize + scopes: + read:pets: Read pets + clientCredentials: + tokenUrl: https://example.com/token + scopes: + admin: Admin access + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'security-scopes-defined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report on scopes of non-oauth2 or undefined security schemes', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + security: + - api_key: + - some:role + - oidc: + - openid + - unknown_scheme: + - read:pets + paths: {} + components: + securitySchemes: + api_key: + type: apiKey + in: header + name: X-Api-Key + oidc: + type: openIdConnect + openIdConnectUrl: https://example.com/.well-known/openid-configuration + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'security-scopes-defined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should resolve $ref-ed flows when collecting defined scopes', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + info: + title: Example API + version: 1.0.0 + paths: + /pets: + get: + security: + - shared_flows_auth: + - read:pets + - shared_flow_auth: + - read:pets + - pets:admin + responses: + '200': + description: ok + components: + securitySchemes: + base_auth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/authorize + tokenUrl: https://example.com/token + scopes: + read:pets: Read pets + shared_flows_auth: + type: oauth2 + flows: + $ref: '#/components/securitySchemes/base_auth/flows' + shared_flow_auth: + type: oauth2 + flows: + authorizationCode: + $ref: '#/components/securitySchemes/base_auth/flows/authorizationCode' + `, + 'openapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'security-scopes-defined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/paths/~1pets/get/security/1/shared_flow_auth/1", + "reportOnKey": false, + "source": "openapi.yaml", + }, + ], + "message": "The "pets:admin" scope is not defined in the "shared_flow_auth" security scheme.", + "reference": "https://redocly.com/docs/cli/rules/common/security-scopes-defined", + "ruleId": "security-scopes-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report oauth2 requirements without scopes when requireScopes is set', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pets: + get: + security: + - petstore_auth: [] + components: + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://example.com/authorize + scopes: + read:pets: Read pets + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-scopes-defined': { severity: 'error', requireScopes: true } }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/paths/~1pets/get/security/0/petstore_auth", + "reportOnKey": true, + "source": "foobar.yaml", + }, + ], + "message": "The "petstore_auth" security requirement must list at least one scope.", + "reference": "https://redocly.com/docs/cli/rules/common/security-scopes-defined", + "ruleId": "security-scopes-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); +}); + +describe('Oas2 security-scopes-defined', () => { + it('should report scopes that are not defined in the security scheme', async () => { + const document = parseYamlToDocument( + outdent` + swagger: '2.0' + security: + - petstore_auth: + - read:pet + paths: {} + securityDefinitions: + petstore_auth: + type: oauth2 + flow: implicit + authorizationUrl: https://example.com/authorize + scopes: + read:pets: Read pets + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'security-scopes-defined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/security/0/petstore_auth/0", + "reportOnKey": false, + "source": "foobar.yaml", + }, + ], + "message": "The "read:pet" scope is not defined in the "petstore_auth" security scheme.", + "reference": "https://redocly.com/docs/cli/rules/common/security-scopes-defined", + "ruleId": "security-scopes-defined", + "severity": "error", + "suggest": [ + "read:pets", + ], + }, + ] + `); + }); +}); + +describe('Async2 security-scopes-defined', () => { + it('should report scopes that are not defined in the security scheme flows', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Example API + version: 1.0.0 + servers: + production: + url: broker.example.com + protocol: kafka + security: + - broker_auth: + - write:events + channels: {} + components: + securitySchemes: + broker_auth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/token + scopes: + read:events: Read events + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'security-scopes-defined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/servers/production/security/0/broker_auth/0", + "reportOnKey": false, + "source": "asyncapi.yaml", + }, + ], + "message": "The "write:events" scope is not defined in the "broker_auth" security scheme.", + "reference": "https://redocly.com/docs/cli/rules/common/security-scopes-defined", + "ruleId": "security-scopes-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/rules/common/security-scopes-defined.ts b/packages/core/src/rules/common/security-scopes-defined.ts new file mode 100644 index 0000000000..38711c1e9a --- /dev/null +++ b/packages/core/src/rules/common/security-scopes-defined.ts @@ -0,0 +1,94 @@ +import { isRef, type Location } from '../../ref-utils.js'; +import type { Oas3SecurityScheme, Referenced } from '../../typings/openapi.js'; +import type { Oas2SecurityScheme } from '../../typings/swagger.js'; +import type { Oas2Rule, Oas3Rule } from '../../visitors.js'; +import type { ResolveFn, UserContext } from '../../walk.js'; +import { getSuggest } from '../utils.js'; + +// AsyncAPI 2 OAuth2 schemes share the `flows.*.scopes` shape with OAS3. +type SecurityScheme = Oas2SecurityScheme | Oas3SecurityScheme; +type OAuth2Flow = { scopes?: Record }; + +// The `flows` object and each flow can be behind a `$ref`, so they are +// resolved relative to the file that contains them. +function getDefinedScopes( + scheme: SecurityScheme, + resolve: ResolveFn, + schemeLocation: Location +): string[] { + if ('flows' in scheme && scheme.flows) { + const { node: flows, location: flowsLocation } = isRef(scheme.flows) + ? resolve>>( + scheme.flows, + schemeLocation.source.absoluteRef + ) + : { node: scheme.flows, location: schemeLocation }; + const flowsSource = (flowsLocation ?? schemeLocation).source.absoluteRef; + + const scopes = Object.values(flows ?? {}).flatMap((flow) => { + const resolvedFlow = isRef(flow) ? resolve(flow, flowsSource).node : flow; + return Object.keys(resolvedFlow?.scopes || {}); + }); + return [...new Set(scopes)]; + } + // OAS2 schemes list scopes directly on the scheme + return Object.keys((scheme as Oas2SecurityScheme).scopes || {}); +} + +export const SecurityScopesDefined: Oas3Rule | Oas2Rule = (opts: { requireScopes?: boolean }) => { + const definedSchemes = new Map(); + const usedScopes: Array<{ schemeName: string; scopes: string[]; location: Location }> = []; + + return { + Root: { + leave(_root: unknown, { report }: UserContext) { + for (const { schemeName, scopes, location } of usedScopes) { + const scheme = definedSchemes.get(schemeName); + + // Undefined schemes are reported by `security-defined`; + // other scheme types don't declare scopes that can be checked statically. + if (scheme?.type !== 'oauth2') continue; + + if (opts.requireScopes && scopes.length === 0) { + report({ + message: `The "${schemeName}" security requirement must list at least one scope.`, + location: location.key(), + reference: 'https://redocly.com/docs/cli/rules/common/security-scopes-defined', + }); + continue; + } + + for (let scopeIndex = 0; scopeIndex < scopes.length; scopeIndex++) { + const scope = scopes[scopeIndex]; + if (!scheme.definedScopes.includes(scope)) { + report({ + message: `The "${scope}" scope is not defined in the "${schemeName}" security scheme.`, + location: location.child([scopeIndex]), + suggest: getSuggest(scope, scheme.definedScopes), + reference: 'https://redocly.com/docs/cli/rules/common/security-scopes-defined', + }); + } + } + } + }, + }, + SecurityScheme(scheme: SecurityScheme, { key, resolve, location }: UserContext) { + definedSchemes.set(key.toString(), { + type: scheme.type, + definedScopes: scheme.type === 'oauth2' ? getDefinedScopes(scheme, resolve, location) : [], + }); + }, + SecurityRequirement( + requirement: Record, + { location }: UserContext + ) { + for (const [schemeName, scopes] of Object.entries(requirement)) { + usedScopes.push({ + schemeName, + scopes: scopes || [], + location: location.child([schemeName]), + }); + } + }, + }; +}; diff --git a/packages/core/src/rules/oas2/index.ts b/packages/core/src/rules/oas2/index.ts index 3e6d20bbab..cae23097c6 100644 --- a/packages/core/src/rules/oas2/index.ts +++ b/packages/core/src/rules/oas2/index.ts @@ -36,6 +36,7 @@ import { RequiredStringPropertyMissingMinLength } from '../common/required-strin import { ResponseContainsHeader } from '../common/response-contains-header.js'; import { ScalarPropertyMissingExample } from '../common/scalar-property-missing-example.js'; import { SecurityDefined } from '../common/security-defined.js'; +import { SecurityScopesDefined } from '../common/security-scopes-defined.js'; import { SpecStrictRefs } from '../common/spec-strict-refs.js'; import { Struct } from '../common/struct.js'; import { TagDescription } from '../common/tag-description.js'; @@ -75,6 +76,7 @@ export const rules: Oas2RuleSet<'built-in'> = { 'parameter-description': ParameterDescription as Oas2Rule, 'operation-singular-tag': OperationSingularTag as Oas2Rule, 'security-defined': SecurityDefined as Oas2Rule, + 'security-scopes-defined': SecurityScopesDefined as Oas2Rule, 'no-unresolved-refs': NoUnresolvedRefs as Oas2Rule, 'no-identical-paths': NoIdenticalPaths as Oas2Rule, 'no-ambiguous-paths': NoAmbiguousPaths as Oas2Rule, diff --git a/packages/core/src/rules/oas3/index.ts b/packages/core/src/rules/oas3/index.ts index 2518307aea..4906dfb4c8 100644 --- a/packages/core/src/rules/oas3/index.ts +++ b/packages/core/src/rules/oas3/index.ts @@ -37,6 +37,7 @@ import { RequiredStringPropertyMissingMinLength } from '../common/required-strin import { ResponseContainsHeader } from '../common/response-contains-header.js'; import { ScalarPropertyMissingExample } from '../common/scalar-property-missing-example.js'; import { SecurityDefined } from '../common/security-defined.js'; +import { SecurityScopesDefined } from '../common/security-scopes-defined.js'; import { SpecStrictRefs } from '../common/spec-strict-refs.js'; import { Struct } from '../common/struct.js'; import { TagDescription } from '../common/tag-description.js'; @@ -97,6 +98,7 @@ export const rules: Oas3RuleSet<'built-in'> = { 'parameter-description': ParameterDescription as Oas3Rule, 'operation-singular-tag': OperationSingularTag as Oas3Rule, 'security-defined': SecurityDefined as Oas3Rule, + 'security-scopes-defined': SecurityScopesDefined as Oas3Rule, 'no-unresolved-refs': NoUnresolvedRefs as Oas3Rule, 'paths-kebab-case': PathsKebabCase as Oas3Rule, 'boolean-parameter-prefixes': BooleanParameterPrefixes, diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index c078ab4c42..44fc928835 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -47,6 +47,7 @@ const builtInOAS2Rules = [ 'response-contains-header', 'scalar-property-missing-example', 'security-defined', + 'security-scopes-defined', 'spec-strict-refs', 'no-required-schema-properties-undefined', 'no-schema-type-mismatch', @@ -93,6 +94,7 @@ const builtInOAS3Rules = [ 'response-contains-header', 'scalar-property-missing-example', 'security-defined', + 'security-scopes-defined', 'spec-strict-refs', 'no-required-schema-properties-undefined', 'no-schema-type-mismatch', @@ -136,6 +138,7 @@ const builtInAsync2Rules = [ 'no-enum-type-mismatch', 'no-mixed-number-range-constraints', 'no-schema-type-mismatch', + 'security-scopes-defined', ] as const; export type BuiltInAsync2RuleId = (typeof builtInAsync2Rules)[number]; @@ -152,6 +155,7 @@ const builtInAsync3Rules = [ 'no-enum-type-mismatch', 'no-mixed-number-range-constraints', 'no-schema-type-mismatch', + 'security-scopes-defined', ] as const; export type BuiltInAsync3RuleId = (typeof builtInAsync3Rules)[number]; diff --git a/packages/core/src/typings/asyncapi3.ts b/packages/core/src/typings/asyncapi3.ts index a76978e6a9..d7f8afe4bd 100644 --- a/packages/core/src/typings/asyncapi3.ts +++ b/packages/core/src/typings/asyncapi3.ts @@ -105,3 +105,40 @@ export type AmqpChannelBindingExchange = { autoDelete?: boolean; vhost?: string; }; + +export type Async3OAuth2Flow = { + authorizationUrl?: string; + tokenUrl?: string; + refreshUrl?: string; + availableScopes?: Record; +}; + +export type Async3SecurityScheme = { + type: + | 'userPassword' + | 'apiKey' + | 'X509' + | 'symmetricEncryption' + | 'asymmetricEncryption' + | 'httpApiKey' + | 'http' + | 'oauth2' + | 'openIdConnect' + | 'plain' + | 'scramSha256' + | 'scramSha512' + | 'gssapi'; + description?: string; + name?: string; + in?: string; + scheme?: string; + bearerFormat?: string; + flows?: { + implicit?: Async3OAuth2Flow; + password?: Async3OAuth2Flow; + clientCredentials?: Async3OAuth2Flow; + authorizationCode?: Async3OAuth2Flow; + }; + openIdConnectUrl?: string; + scopes?: string[]; +};