diff --git a/.changeset/oauth2-x-security-token-exchange.md b/.changeset/oauth2-x-security-token-exchange.md new file mode 100644 index 0000000000..0b2ccd3262 --- /dev/null +++ b/.changeset/oauth2-x-security-token-exchange.md @@ -0,0 +1,7 @@ +--- +'@redocly/respect-core': minor +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Added OAuth2 token exchange for `x-security` schemes with the `password` and `clientCredentials` flows. Respect fetches the access token from `tokenUrl` and apply `Authorization: Bearer` to the request, which allows to manually obtain a `accessToken`. The `x-security-scheme-required-values` rule now validates the credentials required by the declared flow. Pre-fetched `accessToken` values continue to work. diff --git a/packages/core/src/rules/arazzo/__tests__/x-security-scheme-required-values.test.ts b/packages/core/src/rules/arazzo/__tests__/x-security-scheme-required-values.test.ts index d2df5d0a2d..4862de353a 100644 --- a/packages/core/src/rules/arazzo/__tests__/x-security-scheme-required-values.test.ts +++ b/packages/core/src/rules/arazzo/__tests__/x-security-scheme-required-values.test.ts @@ -543,4 +543,394 @@ describe('Arazzo x-security-scheme-required-values', () => { ] `); }); + + it('should report when clientId/clientSecret are missing for OAuth2 clientCredentials flow', async () => { + const document = parseYamlToDocument( + outdent` + arazzo: '1.0.1' + info: + title: Cool API + version: 1.0.0 + description: A cool API + sourceDescriptions: + - name: museum-api + type: openapi + url: openapi.yaml + workflows: + - workflowId: get-museum-hours + x-security: + - scheme: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + values: {} + steps: + - stepId: step-with-openapi-operation + operationId: museum-api.getMuseumHours + `, + 'arazzo.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'x-security-scheme-required-values': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/workflows/0/x-security/0", + "reportOnKey": false, + "source": "arazzo.yaml", + }, + ], + "message": "The \`clientId\` is required when using the oauth2 authentication security schema.", + "ruleId": "x-security-scheme-required-values", + "severity": "error", + "suggest": [], + }, + { + "location": [ + { + "pointer": "#/workflows/0/x-security/0", + "reportOnKey": false, + "source": "arazzo.yaml", + }, + ], + "message": "The \`clientSecret\` is required when using the oauth2 authentication security schema.", + "ruleId": "x-security-scheme-required-values", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should accept clientId + clientSecret for OAuth2 clientCredentials flow', async () => { + const document = parseYamlToDocument( + outdent` + arazzo: '1.0.1' + info: + title: Cool API + version: 1.0.0 + description: A cool API + sourceDescriptions: + - name: museum-api + type: openapi + url: openapi.yaml + workflows: + - workflowId: get-museum-hours + x-security: + - scheme: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + values: + clientId: id + clientSecret: secret + steps: + - stepId: step-with-openapi-operation + operationId: museum-api.getMuseumHours + `, + 'arazzo.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'x-security-scheme-required-values': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when username/password are missing for OAuth2 password flow', async () => { + const document = parseYamlToDocument( + outdent` + arazzo: '1.0.1' + info: + title: Cool API + version: 1.0.0 + description: A cool API + sourceDescriptions: + - name: museum-api + type: openapi + url: openapi.yaml + workflows: + - workflowId: get-museum-hours + x-security: + - scheme: + type: oauth2 + flows: + password: + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + values: {} + steps: + - stepId: step-with-openapi-operation + operationId: museum-api.getMuseumHours + `, + 'arazzo.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'x-security-scheme-required-values': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/workflows/0/x-security/0", + "reportOnKey": false, + "source": "arazzo.yaml", + }, + ], + "message": "The \`username\` is required when using the oauth2 authentication security schema.", + "ruleId": "x-security-scheme-required-values", + "severity": "error", + "suggest": [], + }, + { + "location": [ + { + "pointer": "#/workflows/0/x-security/0", + "reportOnKey": false, + "source": "arazzo.yaml", + }, + ], + "message": "The \`password\` is required when using the oauth2 authentication security schema.", + "ruleId": "x-security-scheme-required-values", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should accept a pre-fetched accessToken for OAuth2 password flow (workaround)', async () => { + const document = parseYamlToDocument( + outdent` + arazzo: '1.0.1' + info: + title: Cool API + version: 1.0.0 + description: A cool API + sourceDescriptions: + - name: museum-api + type: openapi + url: openapi.yaml + workflows: + - workflowId: get-museum-hours + x-security: + - scheme: + type: oauth2 + flows: + password: + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + values: + accessToken: pre-fetched + steps: + - stepId: step-with-openapi-operation + operationId: museum-api.getMuseumHours + `, + 'arazzo.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'x-security-scheme-required-values': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report missing credentials when accessToken is present but empty', async () => { + const document = parseYamlToDocument( + outdent` + arazzo: '1.0.1' + info: + title: Cool API + version: 1.0.0 + description: A cool API + sourceDescriptions: + - name: museum-api + type: openapi + url: openapi.yaml + workflows: + - workflowId: get-museum-hours + x-security: + - scheme: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + values: + accessToken: '' + steps: + - stepId: step-with-openapi-operation + operationId: museum-api.getMuseumHours + `, + 'arazzo.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'x-security-scheme-required-values': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/workflows/0/x-security/0", + "reportOnKey": false, + "source": "arazzo.yaml", + }, + ], + "message": "The \`clientId\` is required when using the oauth2 authentication security schema.", + "ruleId": "x-security-scheme-required-values", + "severity": "error", + "suggest": [], + }, + { + "location": [ + { + "pointer": "#/workflows/0/x-security/0", + "reportOnKey": false, + "source": "arazzo.yaml", + }, + ], + "message": "The \`clientSecret\` is required when using the oauth2 authentication security schema.", + "ruleId": "x-security-scheme-required-values", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should accept username + password when both OAuth2 flows are defined', async () => { + const document = parseYamlToDocument( + outdent` + arazzo: '1.0.1' + info: + title: Cool API + version: 1.0.0 + description: A cool API + sourceDescriptions: + - name: museum-api + type: openapi + url: openapi.yaml + workflows: + - workflowId: get-museum-hours + x-security: + - scheme: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + password: + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + values: + username: alice + password: hunter2 + steps: + - stepId: step-with-openapi-operation + operationId: museum-api.getMuseumHours + `, + 'arazzo.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'x-security-scheme-required-values': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should accept clientId + clientSecret when both OAuth2 flows are defined', async () => { + const document = parseYamlToDocument( + outdent` + arazzo: '1.0.1' + info: + title: Cool API + version: 1.0.0 + description: A cool API + sourceDescriptions: + - name: museum-api + type: openapi + url: openapi.yaml + workflows: + - workflowId: get-museum-hours + x-security: + - scheme: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + password: + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + values: + clientId: id + clientSecret: secret + steps: + - stepId: step-with-openapi-operation + operationId: museum-api.getMuseumHours + `, + 'arazzo.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'x-security-scheme-required-values': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); }); diff --git a/packages/core/src/rules/async3/security-defined.ts b/packages/core/src/rules/async3/security-defined.ts new file mode 100644 index 0000000000..dd5ab932de --- /dev/null +++ b/packages/core/src/rules/async3/security-defined.ts @@ -0,0 +1,83 @@ +import { isRef, type Location } from '../../ref-utils.js'; +import type { Async3Rule } from '../../visitors.js'; +import type { UserContext } from '../../walk.js'; + +type SecurityReference = { + location: Location; + name: string; + resolvedAbsolutePointer?: string; + resolved: boolean; +}; + +export const SecurityDefined: Async3Rule = () => { + const definedSchemeAbsolutePointers = new Set(); + const references: SecurityReference[] = []; + const operationsWithoutSecurity: Location[] = []; + let eachOperationHasSecurity = true; + + return { + Root: { + leave(_root: unknown, { report }: UserContext) { + for (const reference of references) { + if ( + reference.resolved && + reference.resolvedAbsolutePointer && + definedSchemeAbsolutePointers.has(reference.resolvedAbsolutePointer) + ) { + continue; + } + + if (!reference.resolved) { + report({ + message: `There is no \`${reference.name}\` security scheme defined.`, + location: reference.location.key(), + }); + } else { + report({ + message: `Security scheme \`$ref\` must point to \`#/components/securitySchemes\`.`, + location: reference.location.key(), + }); + } + } + + if (!eachOperationHasSecurity) { + for (const operationLocation of operationsWithoutSecurity) { + report({ + message: `Every operation should have security defined on it.`, + location: operationLocation.key(), + }); + } + } + }, + }, + NamedSecuritySchemes: { + SecurityScheme(_scheme: unknown, { location }: UserContext) { + definedSchemeAbsolutePointers.add(location.absolutePointer.toString()); + }, + }, + SecuritySchemeList: { + enter(list: unknown[] | undefined, { location, resolve }: UserContext) { + if (!list) return; + for (let i = 0; i < list.length; i++) { + const item = list[i]; + if (!isRef(item)) continue; + const itemLocation = location.child([i]); + const resolved = resolve(item); + const name = item.$ref.split('/').pop() ?? item.$ref; + references.push({ + location: itemLocation, + name, + resolvedAbsolutePointer: resolved.location?.absolutePointer.toString(), + resolved: resolved.node !== undefined, + }); + } + }, + }, + Operation(operation: { security?: unknown }, { location }: UserContext) { + if (!operation?.security) { + eachOperationHasSecurity = false; + operationsWithoutSecurity.push(location); + } + }, + }; +}; diff --git a/packages/core/src/rules/respect/x-security-scheme-required-values.ts b/packages/core/src/rules/respect/x-security-scheme-required-values.ts index 8df40ab8d9..e0f50f7bc6 100644 --- a/packages/core/src/rules/respect/x-security-scheme-required-values.ts +++ b/packages/core/src/rules/respect/x-security-scheme-required-values.ts @@ -1,5 +1,6 @@ import { logger } from '../../logger.js'; import type { ExtendedSecurity } from '../../typings/arazzo.js'; +import type { OAuth2Auth } from '../../typings/openapi.js'; import type { Arazzo1Rule } from '../../visitors.js'; import type { UserContext } from '../../walk.js'; @@ -8,12 +9,40 @@ const REQUIRED_VALUES_BY_AUTH_TYPE = { basic: ['username', 'password'], digest: ['username', 'password'], bearer: ['token'], - oauth2: ['accessToken'], openIdConnect: ['accessToken'], mutualTLS: [], } as const; -type AuthType = keyof typeof REQUIRED_VALUES_BY_AUTH_TYPE; +type AuthType = keyof typeof REQUIRED_VALUES_BY_AUTH_TYPE | 'oauth2'; + +function getOAuth2RequiredValues( + flows: OAuth2Auth['flows'] | undefined, + values: Record | undefined +): readonly string[] { + if (values?.accessToken) { + return []; + } + + const hasClientCredentialsFlow = Boolean(flows?.clientCredentials); + const hasPasswordFlow = Boolean(flows?.password); + + if (hasClientCredentialsFlow && hasPasswordFlow) { + const hasClientCredentials = !!(values && values.clientId && values.clientSecret); + const hasPasswordCredentials = !!(values && values.username && values.password); + if (hasClientCredentials || hasPasswordCredentials) { + return []; + } + return ['clientId', 'clientSecret']; + } + + if (hasClientCredentialsFlow) { + return ['clientId', 'clientSecret']; + } + if (hasPasswordFlow) { + return ['username', 'password']; + } + return ['accessToken']; +} function validateSecuritySchemas( extendedSecurity: ExtendedSecurity[] | undefined, @@ -31,7 +60,7 @@ function validateSecuritySchemas( const { scheme, values } = securitySchema; // TODO: Struct rule does not check before this point, so we need to check it here. Investigate if we can move this check to the Struct rule. - const authType = scheme?.type === 'http' ? scheme.scheme : scheme?.type; + const authType = (scheme?.type === 'http' ? scheme.scheme : scheme?.type) as AuthType; if (authType === 'mutualTLS') { logger.warn( @@ -40,7 +69,10 @@ function validateSecuritySchemas( continue; } - const requiredValues = REQUIRED_VALUES_BY_AUTH_TYPE[authType as AuthType]; + const requiredValues = + authType === 'oauth2' + ? getOAuth2RequiredValues((scheme as OAuth2Auth)?.flows, values as Record) + : REQUIRED_VALUES_BY_AUTH_TYPE[authType as keyof typeof REQUIRED_VALUES_BY_AUTH_TYPE]; if (requiredValues) { for (const requiredValue of requiredValues) { diff --git a/packages/respect-core/src/modules/__tests__/flow-runner/resolve-x-security-parameters.ts.test.ts b/packages/respect-core/src/modules/__tests__/flow-runner/resolve-x-security-parameters.ts.test.ts index dd977bd301..2d7ea6afc9 100644 --- a/packages/respect-core/src/modules/__tests__/flow-runner/resolve-x-security-parameters.ts.test.ts +++ b/packages/respect-core/src/modules/__tests__/flow-runner/resolve-x-security-parameters.ts.test.ts @@ -11,7 +11,7 @@ describe('resolveXSecurityParameters', () => { }, } as TestContext; - it('should resolve x-security parameters', () => { + it('should resolve x-security parameters', async () => { const runtimeContext = { $steps: { basicAuth: { @@ -38,7 +38,7 @@ describe('resolveXSecurityParameters', () => { ], } as unknown as Step; - const parameters = resolveXSecurityParameters({ + const parameters = await resolveXSecurityParameters({ ctx, runtimeContext, step, @@ -55,7 +55,7 @@ describe('resolveXSecurityParameters', () => { }); }); - it('should merge x-security schemes on workflow level to steps', () => { + it('should merge x-security schemes on workflow level to steps', async () => { const runtimeContext = { $steps: { basicAuth: { @@ -135,7 +135,7 @@ describe('resolveXSecurityParameters', () => { securitySchemes: { MuseumPlaceholderAuth: { type: 'http', scheme: 'basic' } }, } as any; - const parameters = resolveXSecurityParameters({ + const parameters = await resolveXSecurityParameters({ ctx, runtimeContext, step, @@ -182,7 +182,92 @@ describe('resolveXSecurityParameters', () => { ]); }); - it('should throw when schemeName is provided but scheme cannot be resolved', () => { + it('should exchange OAuth2 clientCredentials for an accessToken and inject it', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ access_token: 'exchanged-cc-token' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as unknown as typeof fetch; + + const ctxWithFetch = { + secretsSet: new Set(), + options: { logger, fetch: fetchMock, maxFetchTimeout: 30_000 }, + } as unknown as TestContext; + + const runtimeContext = {} as RuntimeExpressionContext; + const step = { + stepId: 'getPet', + 'x-security': [ + { + scheme: { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + }, + }, + values: { clientId: 'id', clientSecret: 'secret' }, + }, + ], + } as unknown as Step; + + const parameters = await resolveXSecurityParameters({ + ctx: ctxWithFetch, + runtimeContext, + step, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(parameters).toEqual([ + { name: 'Authorization', in: 'header', value: 'Bearer exchanged-cc-token' }, + ]); + expect(step['x-security']?.[0]?.values?.accessToken).toBe('exchanged-cc-token'); + }); + + it('should skip OAuth2 token exchange when an accessToken is already provided', async () => { + const fetchMock = vi.fn() as unknown as typeof fetch; + + const ctxWithFetch = { + secretsSet: new Set(), + options: { logger, fetch: fetchMock, maxFetchTimeout: 30_000 }, + } as unknown as TestContext; + + const runtimeContext = {} as RuntimeExpressionContext; + const step = { + stepId: 'getPet', + 'x-security': [ + { + scheme: { + type: 'oauth2', + flows: { + password: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + }, + }, + values: { accessToken: 'pre-fetched' }, + }, + ], + } as unknown as Step; + + const parameters = await resolveXSecurityParameters({ + ctx: ctxWithFetch, + runtimeContext, + step, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(parameters).toEqual([ + { name: 'Authorization', in: 'header', value: 'Bearer pre-fetched' }, + ]); + }); + + it('should throw when schemeName is provided but scheme cannot be resolved', async () => { const runtimeContext = {} as RuntimeExpressionContext; const step = { stepId: 'getPet', @@ -196,8 +281,8 @@ describe('resolveXSecurityParameters', () => { $sourceDescriptions: { 'museum-api': { components: { securitySchemes: {} } } }, }; - expect(() => resolveXSecurityParameters({ ctx: ctxWithSources, runtimeContext, step })).toThrow( - 'Security scheme "$sourceDescriptions.museum-api.Missing" not found' - ); + await expect( + resolveXSecurityParameters({ ctx: ctxWithSources, runtimeContext, step }) + ).rejects.toThrow('Security scheme "$sourceDescriptions.museum-api.Missing" not found'); }); }); diff --git a/packages/respect-core/src/modules/__tests__/flow-runner/validate-x-security-parameters.test.ts b/packages/respect-core/src/modules/__tests__/flow-runner/validate-x-security-parameters.test.ts index 62e3591a96..a5bb80aeaa 100644 --- a/packages/respect-core/src/modules/__tests__/flow-runner/validate-x-security-parameters.test.ts +++ b/packages/respect-core/src/modules/__tests__/flow-runner/validate-x-security-parameters.test.ts @@ -1,4 +1,10 @@ -import type { Oas3SecurityScheme, ApiKeyAuth, BasicAuth, BearerAuth } from '@redocly/openapi-core'; +import type { + Oas3SecurityScheme, + ApiKeyAuth, + BasicAuth, + BearerAuth, + OAuth2Auth, +} from '@redocly/openapi-core'; import { validateXSecurityParameters } from '../../flow-runner/validate-x-security-parameters.js'; @@ -64,6 +70,102 @@ describe('validateXSecurityParameters', () => { ); }); + it('should validate oauth2 scheme with pre-fetched accessToken (workaround)', () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + password: { + tokenUrl: 'https://example.com/token', + scopes: { read: 'Read access' }, + }, + }, + }; + const values = { accessToken: 'pre-fetched-token' }; + + const result = validateXSecurityParameters({ scheme, values }); + expect(result).toEqual({ scheme, values }); + }); + + it('should validate oauth2 clientCredentials with clientId + clientSecret', () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/token', + scopes: { read: 'Read access' }, + }, + }, + }; + const values = { clientId: 'id', clientSecret: 'secret' }; + + const result = validateXSecurityParameters({ scheme, values }); + expect(result).toEqual({ scheme, values }); + }); + + it('should throw when clientId is missing for oauth2 clientCredentials flow', () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/token', + scopes: { read: 'Read access' }, + }, + }, + }; + + expect(() => + validateXSecurityParameters({ scheme, values: { clientSecret: 'secret' } }) + ).toThrow('Missing required value `clientId` for oauth2 security scheme'); + }); + + it('should validate oauth2 password flow with username + password', () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + password: { + tokenUrl: 'https://example.com/token', + scopes: { read: 'Read access' }, + }, + }, + }; + const values = { username: 'alice', password: 'hunter2' }; + + const result = validateXSecurityParameters({ scheme, values }); + expect(result).toEqual({ scheme, values }); + }); + + it('should throw when password is missing for oauth2 password flow', () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + password: { + tokenUrl: 'https://example.com/token', + scopes: { read: 'Read access' }, + }, + }, + }; + + expect(() => validateXSecurityParameters({ scheme, values: { username: 'alice' } })).toThrow( + 'Missing required value `password` for oauth2 security scheme' + ); + }); + + it('should require accessToken for oauth2 implicit flow', () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'https://example.com/auth', + scopes: { read: 'Read access' }, + }, + }, + }; + + expect(() => validateXSecurityParameters({ scheme, values: {} })).toThrow( + 'Missing required value `accessToken` for oauth2 security scheme' + ); + }); + it('should throw an error for unsupported security scheme type', () => { const scheme = { type: 'unknown' } as unknown as Oas3SecurityScheme; const values = { accessToken: 'xyz' }; diff --git a/packages/respect-core/src/modules/flow-runner/prepare-request.ts b/packages/respect-core/src/modules/flow-runner/prepare-request.ts index 419b7a9926..0006ca0a18 100644 --- a/packages/respect-core/src/modules/flow-runner/prepare-request.ts +++ b/packages/respect-core/src/modules/flow-runner/prepare-request.ts @@ -157,7 +157,7 @@ export async function prepareRequest( const workflowLevelXSecurityParameters = activeWorkflow?.['x-security'] || []; - const xSecurityParameters = resolveXSecurityParameters({ + const xSecurityParameters = await resolveXSecurityParameters({ ctx: ctxWithInputs, runtimeContext: expressionContext, step, diff --git a/packages/respect-core/src/modules/flow-runner/resolve-x-security-parameters.ts b/packages/respect-core/src/modules/flow-runner/resolve-x-security-parameters.ts index b836018939..2e36929fe4 100644 --- a/packages/respect-core/src/modules/flow-runner/resolve-x-security-parameters.ts +++ b/packages/respect-core/src/modules/flow-runner/resolve-x-security-parameters.ts @@ -1,7 +1,11 @@ -import type { ExtendedSecurity } from '@redocly/openapi-core'; +import type { ExtendedSecurity, OAuth2Auth } from '@redocly/openapi-core'; import type { Oas3SecurityScheme } from 'core/src/typings/openapi.js'; import type { Step, RuntimeExpressionContext, TestContext } from '../../types.js'; +import { + exchangeOAuth2Token, + pickOAuth2ExchangeableFlow, +} from '../../utils/oauth2/exchange-oauth2-token.js'; import { getSecurityParameter } from '../context-parser/get-security-parameters.js'; import type { ParameterWithIn } from '../context-parser/index.js'; import type { OperationDetails } from '../description-parser/get-operation-from-description.js'; @@ -9,7 +13,7 @@ import { evaluateRuntimeExpressionPayload } from '../runtime-expressions/index.j import { resolveSecurityScheme } from './resolve-security-scheme.js'; import { validateXSecurityParameters } from './validate-x-security-parameters.js'; -export function resolveXSecurityParameters({ +export async function resolveXSecurityParameters({ ctx, runtimeContext, step, @@ -21,7 +25,7 @@ export function resolveXSecurityParameters({ step: Step; operation?: OperationDetails & { securitySchemes: Record }; workflowLevelXSecurityParameters?: ExtendedSecurity[]; -}): ParameterWithIn[] { +}): Promise { const stepXSecurity = step['x-security'] as ExtendedSecurity[] | undefined; const workflowLevelXSecurity = workflowLevelXSecurityParameters as ExtendedSecurity[] | undefined; @@ -57,6 +61,18 @@ export function resolveXSecurityParameters({ }) ); + if ( + scheme.type === 'oauth2' && + !values.accessToken && + pickOAuth2ExchangeableFlow(scheme as OAuth2Auth, values) + ) { + values.accessToken = await exchangeOAuth2Token({ + scheme: scheme as OAuth2Auth, + values, + ctx, + }); + } + const resolvedSecurity = validateXSecurityParameters({ scheme, values }); const param = getSecurityParameter(resolvedSecurity, ctx); diff --git a/packages/respect-core/src/modules/flow-runner/validate-x-security-parameters.ts b/packages/respect-core/src/modules/flow-runner/validate-x-security-parameters.ts index b6a4b16f58..eb36a73f09 100644 --- a/packages/respect-core/src/modules/flow-runner/validate-x-security-parameters.ts +++ b/packages/respect-core/src/modules/flow-runner/validate-x-security-parameters.ts @@ -1,16 +1,15 @@ -import type { Oas3SecurityScheme, ResolvedSecurity } from '@redocly/openapi-core'; +import type { OAuth2Auth, Oas3SecurityScheme, ResolvedSecurity } from '@redocly/openapi-core'; const REQUIRED_VALUES_BY_AUTH_TYPE = { apiKey: ['apiKey'], basic: ['username', 'password'], digest: ['username', 'password'], bearer: ['token'], - oauth2: ['accessToken'], openIdConnect: ['accessToken'], mutualTLS: [], } as const; -type AuthType = keyof typeof REQUIRED_VALUES_BY_AUTH_TYPE; +type AuthType = keyof typeof REQUIRED_VALUES_BY_AUTH_TYPE | 'oauth2'; // TODO: This should be replaced with schema validation in Respect rules export function validateXSecurityParameters({ @@ -20,8 +19,20 @@ export function validateXSecurityParameters({ scheme: Oas3SecurityScheme; values: Record; }): ResolvedSecurity { - const authType = scheme.type === 'http' ? scheme.scheme : scheme.type; - const requiredKeys = REQUIRED_VALUES_BY_AUTH_TYPE[authType as AuthType]; + const authType = (scheme.type === 'http' ? scheme.scheme : scheme.type) as AuthType; + + if (authType === 'oauth2') { + const requiredKeys = getRequiredValuesForOAuth2((scheme as OAuth2Auth).flows, values); + for (const key of requiredKeys) { + if (!values?.[key]) { + throw new Error(`Missing required value \`${key}\` for oauth2 security scheme`); + } + } + return { scheme, values } as ResolvedSecurity; + } + + const requiredKeys = + REQUIRED_VALUES_BY_AUTH_TYPE[authType as keyof typeof REQUIRED_VALUES_BY_AUTH_TYPE]; if (!requiredKeys) { throw new Error(`Unsupported security scheme type: ${authType}`); @@ -35,3 +46,23 @@ export function validateXSecurityParameters({ return { scheme, values } as ResolvedSecurity; } + +// It returns required value keys for an OAuth2 scheme based on its declared flow. +export function getRequiredValuesForOAuth2( + flows: OAuth2Auth['flows'] | undefined, + values: Record | undefined +): string[] { + if (values?.accessToken) { + return []; + } + + if (flows?.clientCredentials) { + return ['clientId', 'clientSecret']; + } + + if (flows?.password) { + return ['username', 'password']; + } + + return ['accessToken']; +} diff --git a/packages/respect-core/src/types.ts b/packages/respect-core/src/types.ts index 343a505f20..41b0b413ed 100644 --- a/packages/respect-core/src/types.ts +++ b/packages/respect-core/src/types.ts @@ -297,6 +297,7 @@ export type TestContext = RuntimeExpressionContext & { noSecretsMasking: boolean; severity: Record; apiClient: ApiFetcher; + oauth2TokenCache?: Map; }; export type TestDescription = Partial< diff --git a/packages/respect-core/src/utils/__tests__/oauth2/exchange-oauth2-token.test.ts b/packages/respect-core/src/utils/__tests__/oauth2/exchange-oauth2-token.test.ts new file mode 100644 index 0000000000..eee2742811 --- /dev/null +++ b/packages/respect-core/src/utils/__tests__/oauth2/exchange-oauth2-token.test.ts @@ -0,0 +1,290 @@ +import type { OAuth2Auth } from '@redocly/openapi-core'; +import type { TestContext } from 'respect-core/src/types.js'; + +import { exchangeOAuth2Token } from '../../oauth2/exchange-oauth2-token.js'; + +function makeCtx(fetchImpl: typeof fetch): TestContext { + return { + secretsSet: new Set(), + options: { + fetch: fetchImpl, + maxFetchTimeout: 30_000, + }, + } as unknown as TestContext; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('exchangeOAuth2Token', () => { + it('exchanges credentials for an access token using the clientCredentials flow', async () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access', write: 'Write access' }, + }, + }, + }; + + const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => + jsonResponse({ access_token: 'cc-token', token_type: 'Bearer' }) + ) as unknown as typeof fetch; + const ctx = makeCtx(fetchMock); + + const token = await exchangeOAuth2Token({ + scheme, + values: { clientId: 'id', clientSecret: 'secret' }, + ctx, + }); + + expect(token).toBe('cc-token'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = (fetchMock as unknown as ReturnType).mock.calls[0]; + expect(url).toBe('https://example.com/oauth/token'); + expect(init.method).toBe('POST'); + + const headers = init.headers as Record; + expect(headers['content-type']).toBe('application/x-www-form-urlencoded'); + expect(headers.authorization).toBe(`Basic ${btoa('id:secret')}`); + + const body = new URLSearchParams(init.body as string); + expect(body.get('grant_type')).toBe('client_credentials'); + expect(body.get('scope')).toBe('read write'); + + expect(ctx.secretsSet.has('secret')).toBe(true); + expect(ctx.secretsSet.has('cc-token')).toBe(true); + }); + + it('sends credentials in the body when clientAuthMethod is "body"', async () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + }, + }; + + const fetchMock = vi.fn(async () => + jsonResponse({ access_token: 'body-token' }) + ) as unknown as typeof fetch; + const ctx = makeCtx(fetchMock); + + await exchangeOAuth2Token({ + scheme, + values: { clientId: 'id', clientSecret: 'secret', clientAuthMethod: 'body' }, + ctx, + }); + + const [, init] = (fetchMock as unknown as ReturnType).mock.calls[0]; + const headers = init.headers as Record; + expect(headers.authorization).toBeUndefined(); + + const body = new URLSearchParams(init.body as string); + expect(body.get('client_id')).toBe('id'); + expect(body.get('client_secret')).toBe('secret'); + }); + + it('exchanges credentials for an access token using the password flow', async () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + password: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + }, + }; + + const fetchMock = vi.fn(async () => + jsonResponse({ access_token: 'pwd-token' }) + ) as unknown as typeof fetch; + const ctx = makeCtx(fetchMock); + + const token = await exchangeOAuth2Token({ + scheme, + values: { username: 'alice', password: 'hunter2' }, + ctx, + }); + + expect(token).toBe('pwd-token'); + + const [, init] = (fetchMock as unknown as ReturnType).mock.calls[0]; + const body = new URLSearchParams(init.body as string); + expect(body.get('grant_type')).toBe('password'); + expect(body.get('username')).toBe('alice'); + expect(body.get('password')).toBe('hunter2'); + + expect(ctx.secretsSet.has('hunter2')).toBe(true); + expect(ctx.secretsSet.has('pwd-token')).toBe(true); + }); + + it('prefers the password flow over clientCredentials when both flows are defined and username/password are provided', async () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + password: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + }, + }; + + const fetchMock = vi.fn(async () => + jsonResponse({ access_token: 'mixed-token' }) + ) as unknown as typeof fetch; + const ctx = makeCtx(fetchMock); + + await exchangeOAuth2Token({ + scheme, + values: { + username: 'alice', + password: 'hunter2', + clientId: 'id', + clientSecret: 'secret', + }, + ctx, + }); + + const [, init] = (fetchMock as unknown as ReturnType).mock.calls[0]; + const body = new URLSearchParams(init.body as string); + expect(body.get('grant_type')).toBe('password'); + expect(body.get('username')).toBe('alice'); + expect(body.get('password')).toBe('hunter2'); + // Client credentials still authenticate the password flow's client. + const headers = init.headers as Record; + expect(headers.authorization).toBe(`Basic ${btoa('id:secret')}`); + }); + + it('re-fetches the token when the cached entry is past its expiry', async () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + }, + }; + + let call = 0; + const fetchMock = vi.fn(async () => { + call += 1; + return jsonResponse({ access_token: `token-${call}`, expires_in: 1 }); + }) as unknown as typeof fetch; + const ctx = makeCtx(fetchMock); + + const first = await exchangeOAuth2Token({ + scheme, + values: { clientId: 'id', clientSecret: 'secret' }, + ctx, + }); + + const [[, cached]] = Array.from(ctx.oauth2TokenCache!.entries()); + cached.expiresAt = Date.now(); + + const second = await exchangeOAuth2Token({ + scheme, + values: { clientId: 'id', clientSecret: 'secret' }, + ctx, + }); + + expect(first).toBe('token-1'); + expect(second).toBe('token-2'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('caches the token so a second call does not re-fetch', async () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + }, + }; + + const fetchMock = vi.fn(async () => + jsonResponse({ access_token: 'cached-token' }) + ) as unknown as typeof fetch; + const ctx = makeCtx(fetchMock); + + const first = await exchangeOAuth2Token({ + scheme, + values: { clientId: 'id', clientSecret: 'secret' }, + ctx, + }); + const second = await exchangeOAuth2Token({ + scheme, + values: { clientId: 'id', clientSecret: 'secret' }, + ctx, + }); + + expect(first).toBe('cached-token'); + expect(second).toBe('cached-token'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('throws a typed error on a non-2xx response', async () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + }, + }; + + const fetchMock = vi.fn( + async () => new Response('invalid_client', { status: 401 }) + ) as unknown as typeof fetch; + const ctx = makeCtx(fetchMock); + + await expect( + exchangeOAuth2Token({ + scheme, + values: { clientId: 'id', clientSecret: 'wrong' }, + ctx, + }) + ).rejects.toThrow('OAuth2 clientCredentials token exchange failed with status 401'); + }); + + it('throws when the response is missing access_token', async () => { + const scheme: OAuth2Auth = { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { read: 'Read access' }, + }, + }, + }; + + const fetchMock = vi.fn(async () => + jsonResponse({ token_type: 'Bearer' }) + ) as unknown as typeof fetch; + const ctx = makeCtx(fetchMock); + + await expect( + exchangeOAuth2Token({ + scheme, + values: { clientId: 'id', clientSecret: 'secret' }, + ctx, + }) + ).rejects.toThrow('missing the `access_token` field'); + }); +}); diff --git a/packages/respect-core/src/utils/oauth2/exchange-oauth2-token.ts b/packages/respect-core/src/utils/oauth2/exchange-oauth2-token.ts new file mode 100644 index 0000000000..b9d0e16ab1 --- /dev/null +++ b/packages/respect-core/src/utils/oauth2/exchange-oauth2-token.ts @@ -0,0 +1,187 @@ +import type { OAuth2Auth } from '@redocly/openapi-core'; + +import { UnexpectedError } from '../../modules/checks/checks.js'; +import { type TestContext } from '../../types.js'; + +export type OAuth2ExchangeableFlow = 'clientCredentials' | 'password'; + +type OAuth2FlowConfig = NonNullable; +const TOKEN_EXPIRY_SKEW_MS = 30_000; +const DEFAULT_TOKEN_LIFETIME_MS = 3_600_000; + +export function pickOAuth2ExchangeableFlow( + scheme: OAuth2Auth, + values: Record +): { flow: OAuth2ExchangeableFlow; config: OAuth2FlowConfig } | undefined { + const flows = scheme.flows ?? {}; + const hasFullUserCredentials = Boolean(values.username && values.password); + const hasFullClientCredentials = Boolean(values.clientId && values.clientSecret); + + if (flows.password && hasFullUserCredentials) { + return { flow: 'password', config: flows.password }; + } + if (flows.clientCredentials && hasFullClientCredentials) { + return { flow: 'clientCredentials', config: flows.clientCredentials }; + } + if (flows.clientCredentials) { + return { flow: 'clientCredentials', config: flows.clientCredentials }; + } + if (flows.password) { + return { flow: 'password', config: flows.password }; + } + return undefined; +} + +function buildScope( + values: Record, + scopes: Record | undefined +): string | undefined { + if (typeof values.scope === 'string' && values.scope.length > 0) { + return values.scope; + } + if (scopes && Object.keys(scopes).length > 0) { + return Object.keys(scopes).join(' '); + } + return undefined; +} + +export async function exchangeOAuth2Token({ + scheme, + values, + ctx, +}: { + scheme: OAuth2Auth; + values: Record; + ctx: TestContext; +}): Promise { + const picked = pickOAuth2ExchangeableFlow(scheme, values); + if (!picked) { + throw new UnexpectedError( + 'OAuth2 token exchange requires a `password` or `clientCredentials` flow to be defined on the security scheme.' + ); + } + + const { flow, config } = picked; + const tokenUrl = config.tokenUrl; + if (!tokenUrl) { + throw new UnexpectedError( + `OAuth2 ${flow} flow is missing required \`tokenUrl\` on the security scheme.` + ); + } + + const clientId = typeof values.clientId === 'string' ? values.clientId : undefined; + const clientSecret = typeof values.clientSecret === 'string' ? values.clientSecret : undefined; + const scope = buildScope(values, config.scopes); + const clientAuthMethod = values.clientAuthMethod === 'body' ? 'body' : 'header'; + + const cacheKey = JSON.stringify([ + flow, + tokenUrl, + clientId ?? '', + clientSecret ?? '', + clientAuthMethod, + flow === 'password' ? (values.username ?? '') : '', + flow === 'password' ? (values.password ?? '') : '', + scope ?? '', + ]); + + if (!ctx.oauth2TokenCache) { + ctx.oauth2TokenCache = new Map(); + } + const cached = ctx.oauth2TokenCache.get(cacheKey); + if (cached && cached.expiresAt - Date.now() > TOKEN_EXPIRY_SKEW_MS) { + return cached.token; + } + + const body = new URLSearchParams(); + if (flow === 'clientCredentials') { + body.set('grant_type', 'client_credentials'); + } else { + body.set('grant_type', 'password'); + if (typeof values.username !== 'string' || typeof values.password !== 'string') { + throw new UnexpectedError( + 'OAuth2 password flow requires both `username` and `password` in `x-security.values`.' + ); + } + body.set('username', values.username); + body.set('password', values.password); + } + if (scope) { + body.set('scope', scope); + } + + const headers: Record = { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }; + + if (clientId !== undefined && clientAuthMethod === 'body') { + body.set('client_id', clientId); + if (clientSecret !== undefined) { + body.set('client_secret', clientSecret); + } + } else if (clientId !== undefined || clientSecret !== undefined) { + const basic = btoa(`${clientId ?? ''}:${clientSecret ?? ''}`); + headers.authorization = `Basic ${basic}`; + } else if (flow === 'clientCredentials') { + throw new UnexpectedError( + 'OAuth2 clientCredentials flow requires `clientId` (and usually `clientSecret`) in `x-security.values`.' + ); + } + + if (clientSecret) { + ctx.secretsSet.add(clientSecret); + } + if (flow === 'password' && typeof values.password === 'string') { + ctx.secretsSet.add(values.password); + } + + const fetcher = ctx.options.fetch ?? fetch; + let response: Response; + try { + response = await fetcher(tokenUrl, { + method: 'POST', + headers, + body: body.toString(), + redirect: 'follow', + signal: AbortSignal.timeout(ctx.options.maxFetchTimeout), + }); + } catch (error) { + throw new UnexpectedError(`OAuth2 ${flow} token exchange failed: ${(error as Error).message}`); + } + + const responseText = await response.text(); + if (!response.ok) { + throw new UnexpectedError( + `OAuth2 ${flow} token exchange failed with status ${response.status}: ${responseText}` + ); + } + + let payload: { access_token?: string; expires_in?: number }; + try { + payload = JSON.parse(responseText); + } catch { + throw new UnexpectedError( + `OAuth2 ${flow} token exchange returned a non-JSON response: ${responseText}` + ); + } + + if (!payload.access_token) { + throw new UnexpectedError( + `OAuth2 ${flow} token exchange response is missing the \`access_token\` field.` + ); + } + + const lifetimeMs = + typeof payload.expires_in === 'number' && payload.expires_in > 0 + ? payload.expires_in * 1000 + : DEFAULT_TOKEN_LIFETIME_MS; + + ctx.secretsSet.add(payload.access_token); + ctx.oauth2TokenCache.set(cacheKey, { + token: payload.access_token, + expiresAt: Date.now() + lifetimeMs, + }); + + return payload.access_token; +} diff --git a/tests/e2e/check-config/wrong-config-type-extensions-in-assertions/snapshot.txt b/tests/e2e/check-config/wrong-config-type-extensions-in-assertions/snapshot.txt index c440e889dc..87010d187f 100644 --- a/tests/e2e/check-config/wrong-config-type-extensions-in-assertions/snapshot.txt +++ b/tests/e2e/check-config/wrong-config-type-extensions-in-assertions/snapshot.txt @@ -1,6 +1,6 @@ [1] redocly.yaml:10:13 at #/rules/rule~1metadata-lifecycle/subject/type -`type` can be one of the following only: "any", "Root", "Tag", "TagList", "TagGroups", "TagGroup", "ExternalDocs", "Example", "ExamplesMap", "EnumDescriptions", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Logo", "Paths", "PathItem", "Parameter", "ParameterItems", "ParameterList", "Operation", "Examples", "Header", "Responses", "Response", "Schema", "Xml", "SchemaProperties", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", "XCodeSample", "XCodeSampleList", "XServerList", "XServer", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Encoding", "EncodingMap", "HeadersMap", "Link", "DiscriminatorMapping", "Discriminator", "Components", "LinksMap", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "XUsePkce", "WebhooksMap", "XMetaData", "PatternProperties", "NamedPathItems", "DependentRequired", "DeviceAuthorization", "NamedMediaTypes", "HttpServerBinding", "HttpChannelBinding", "HttpMessageBinding", "HttpOperationBinding", "WsServerBinding", "WsChannelBinding", "WsMessageBinding", "WsOperationBinding", "KafkaServerBinding", "KafkaTopicConfiguration", "KafkaChannelBinding", "KafkaMessageBinding", "KafkaOperationBinding", "AnypointmqServerBinding", "AnypointmqChannelBinding", "AnypointmqMessageBinding", "AnypointmqOperationBinding", "AmqpServerBinding", "AmqpChannelBinding", "AmqpMessageBinding", "AmqpOperationBinding", "Amqp1ServerBinding", "Amqp1ChannelBinding", "Amqp1MessageBinding", "Amqp1OperationBinding", "MqttServerBindingLastWill", "MqttServerBinding", "MqttChannelBinding", "MqttMessageBinding", "MqttOperationBinding", "Mqtt5ServerBinding", "Mqtt5ChannelBinding", "Mqtt5MessageBinding", "Mqtt5OperationBinding", "NatsServerBinding", "NatsChannelBinding", "NatsMessageBinding", "NatsOperationBinding", "JmsServerBinding", "JmsChannelBinding", "JmsMessageBinding", "JmsOperationBinding", "SolaceServerBinding", "SolaceChannelBinding", "SolaceMessageBinding", "SolaceDestination", "SolaceOperationBinding", "StompServerBinding", "StompChannelBinding", "StompMessageBinding", "StompOperationBinding", "RedisServerBinding", "RedisChannelBinding", "RedisMessageBinding", "RedisOperationBinding", "MercureServerBinding", "MercureChannelBinding", "MercureMessageBinding", "MercureOperationBinding", "ServerBindings", "ChannelBindings", "MessageBindings", "OperationBindings", "ServerMap", "ChannelMap", "Channel", "ParametersMap", "MessageExample", "NamedMessages", "NamedMessageTraits", "NamedOperationTraits", "NamedCorrelationIds", "SecuritySchemeFlows", "Message", "OperationTrait", "OperationTraitList", "MessageTrait", "MessageTraitList", "MessageExampleList", "CorrelationId", "Dependencies", "OperationReply", "OperationReplyAddress", "NamedTags", "NamedExternalDocs", "NamedChannels", "NamedOperations", "NamedOperationReplies", "NamedOperationRelyAddresses", "SecuritySchemeList", "MessageList", "SourceDescriptions", "OpenAPISourceDescription", "ArazzoSourceDescription", "Parameters", "ReusableObject", "Workflows", "Workflow", "Steps", "Step", "Replacement", "ExtendedOperation", "ExtendedSecurityList", "ExtendedSecurity", "Outputs", "CriterionObject", "XPathCriterion", "JSONPathCriterion", "SuccessActionObject", "OnSuccessActionList", "FailureActionObject", "OnFailureActionList", "NamedInputs", "NamedSuccessActions", "NamedFailureActions", "Actions", "Action", "Method", "MethodList", "ContentDescriptor", "ContentDescriptorList", "ExamplePairing", "ExamplePairingList", "ExampleList", "LinkList", "ErrorObject", "ErrorList", "NamedContentDescriptors", "NamedErrors", "NamedExamplePairingObjects", "SpecExtension". +`type` can be one of the following only: "any", "Root", "Tag", "TagList", "TagGroups", "TagGroup", "ExternalDocs", "Example", "ExamplesMap", "EnumDescriptions", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Logo", "Paths", "PathItem", "Parameter", "ParameterItems", "ParameterList", "Operation", "Examples", "Header", "Responses", "Response", "Schema", "Xml", "SchemaProperties", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", "XCodeSample", "XCodeSampleList", "XServerList", "XServer", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Encoding", "EncodingMap", "HeadersMap", "Link", "DiscriminatorMapping", "Discriminator", "Components", "LinksMap", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "XUsePkce", "WebhooksMap", "XMetaData", "PatternProperties", "NamedPathItems", "DependentRequired", "DeviceAuthorization", "NamedMediaTypes", "HttpServerBinding", "HttpChannelBinding", "HttpMessageBinding", "HttpOperationBinding", "WsServerBinding", "WsChannelBinding", "WsMessageBinding", "WsOperationBinding", "KafkaServerBinding", "KafkaTopicConfiguration", "KafkaChannelBinding", "KafkaMessageBinding", "KafkaOperationBinding", "AnypointmqServerBinding", "AnypointmqChannelBinding", "AnypointmqMessageBinding", "AnypointmqOperationBinding", "AmqpServerBinding", "AmqpChannelBinding", "AmqpMessageBinding", "AmqpOperationBinding", "Amqp1ServerBinding", "Amqp1ChannelBinding", "Amqp1MessageBinding", "Amqp1OperationBinding", "MqttServerBindingLastWill", "MqttServerBinding", "MqttChannelBinding", "MqttMessageBinding", "MqttOperationBinding", "Mqtt5ServerBinding", "Mqtt5ChannelBinding", "Mqtt5MessageBinding", "Mqtt5OperationBinding", "NatsServerBinding", "NatsChannelBinding", "NatsMessageBinding", "NatsOperationBinding", "JmsServerBinding", "JmsChannelBinding", "JmsMessageBinding", "JmsOperationBinding", "SolaceServerBinding", "SolaceChannelBinding", "SolaceMessageBinding", "SolaceDestination", "SolaceOperationBinding", "StompServerBinding", "StompChannelBinding", "StompMessageBinding", "StompOperationBinding", "RedisServerBinding", "RedisChannelBinding", "RedisMessageBinding", "RedisOperationBinding", "MercureServerBinding", "MercureChannelBinding", "MercureMessageBinding", "MercureOperationBinding", "ServerBindings", "ChannelBindings", "MessageBindings", "OperationBindings", "ServerMap", "ChannelMap", "Channel", "ParametersMap", "MessageExample", "NamedMessages", "NamedMessageTraits", "NamedOperationTraits", "NamedCorrelationIds", "SecuritySchemeFlows", "Message", "OperationTrait", "OperationTraitList", "MessageTrait", "MessageTraitList", "MessageExampleList", "CorrelationId", "Dependencies", "OperationReply", "OperationReplyAddress", "NamedTags", "NamedExternalDocs", "NamedChannels", "NamedOperations", "NamedOperationReplies", "NamedOperationRelyAddresses", "SecuritySchemeList", "MessageList", "SourceDescriptions", "OpenAPISourceDescription", "ArazzoSourceDescription", "Parameters", "ActionParameters", "ActionParameter", "ReusableObject", "Workflows", "Workflow", "Steps", "Step", "Replacement", "ExtendedOperation", "ExtendedSecurityList", "ExtendedSecurity", "Outputs", "CriterionObject", "XPathCriterion", "JSONPathCriterion", "SuccessActionObject", "OnSuccessActionList", "FailureActionObject", "OnFailureActionList", "NamedInputs", "NamedSuccessActions", "NamedFailureActions", "Actions", "Action", "Method", "MethodList", "ContentDescriptor", "ContentDescriptorList", "ExamplePairing", "ExamplePairingList", "ExampleList", "LinkList", "ErrorObject", "ErrorList", "NamedContentDescriptors", "NamedErrors", "NamedExamplePairingObjects", "SpecExtension". 8 | rule/metadata-lifecycle: 9 | subject: diff --git a/tests/e2e/lint-config/invalid-config-assertation-config-type/snapshot.txt b/tests/e2e/lint-config/invalid-config-assertation-config-type/snapshot.txt index ec4778d4ff..0f20b4ea6e 100644 --- a/tests/e2e/lint-config/invalid-config-assertation-config-type/snapshot.txt +++ b/tests/e2e/lint-config/invalid-config-assertation-config-type/snapshot.txt @@ -1,6 +1,6 @@ [1] redocly.yaml:5:17 at #/rules/rule~1path-item-mutually-required/where/0/subject/type -`type` can be one of the following only: "any", "Root", "Tag", "TagList", "TagGroups", "TagGroup", "ExternalDocs", "Example", "ExamplesMap", "EnumDescriptions", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Logo", "Paths", "PathItem", "Parameter", "ParameterItems", "ParameterList", "Operation", "Examples", "Header", "Responses", "Response", "Schema", "Xml", "SchemaProperties", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", "XCodeSample", "XCodeSampleList", "XServerList", "XServer", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Encoding", "EncodingMap", "HeadersMap", "Link", "DiscriminatorMapping", "Discriminator", "Components", "LinksMap", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "XUsePkce", "WebhooksMap", "PatternProperties", "NamedPathItems", "DependentRequired", "DeviceAuthorization", "NamedMediaTypes", "HttpServerBinding", "HttpChannelBinding", "HttpMessageBinding", "HttpOperationBinding", "WsServerBinding", "WsChannelBinding", "WsMessageBinding", "WsOperationBinding", "KafkaServerBinding", "KafkaTopicConfiguration", "KafkaChannelBinding", "KafkaMessageBinding", "KafkaOperationBinding", "AnypointmqServerBinding", "AnypointmqChannelBinding", "AnypointmqMessageBinding", "AnypointmqOperationBinding", "AmqpServerBinding", "AmqpChannelBinding", "AmqpMessageBinding", "AmqpOperationBinding", "Amqp1ServerBinding", "Amqp1ChannelBinding", "Amqp1MessageBinding", "Amqp1OperationBinding", "MqttServerBindingLastWill", "MqttServerBinding", "MqttChannelBinding", "MqttMessageBinding", "MqttOperationBinding", "Mqtt5ServerBinding", "Mqtt5ChannelBinding", "Mqtt5MessageBinding", "Mqtt5OperationBinding", "NatsServerBinding", "NatsChannelBinding", "NatsMessageBinding", "NatsOperationBinding", "JmsServerBinding", "JmsChannelBinding", "JmsMessageBinding", "JmsOperationBinding", "SolaceServerBinding", "SolaceChannelBinding", "SolaceMessageBinding", "SolaceDestination", "SolaceOperationBinding", "StompServerBinding", "StompChannelBinding", "StompMessageBinding", "StompOperationBinding", "RedisServerBinding", "RedisChannelBinding", "RedisMessageBinding", "RedisOperationBinding", "MercureServerBinding", "MercureChannelBinding", "MercureMessageBinding", "MercureOperationBinding", "ServerBindings", "ChannelBindings", "MessageBindings", "OperationBindings", "ServerMap", "ChannelMap", "Channel", "ParametersMap", "MessageExample", "NamedMessages", "NamedMessageTraits", "NamedOperationTraits", "NamedCorrelationIds", "SecuritySchemeFlows", "Message", "OperationTrait", "OperationTraitList", "MessageTrait", "MessageTraitList", "MessageExampleList", "CorrelationId", "Dependencies", "OperationReply", "OperationReplyAddress", "NamedTags", "NamedExternalDocs", "NamedChannels", "NamedOperations", "NamedOperationReplies", "NamedOperationRelyAddresses", "SecuritySchemeList", "MessageList", "SourceDescriptions", "OpenAPISourceDescription", "ArazzoSourceDescription", "Parameters", "ReusableObject", "Workflows", "Workflow", "Steps", "Step", "Replacement", "ExtendedOperation", "ExtendedSecurityList", "ExtendedSecurity", "Outputs", "CriterionObject", "XPathCriterion", "JSONPathCriterion", "SuccessActionObject", "OnSuccessActionList", "FailureActionObject", "OnFailureActionList", "NamedInputs", "NamedSuccessActions", "NamedFailureActions", "Actions", "Action", "Method", "MethodList", "ContentDescriptor", "ContentDescriptorList", "ExamplePairing", "ExamplePairingList", "ExampleList", "LinkList", "ErrorObject", "ErrorList", "NamedContentDescriptors", "NamedErrors", "NamedExamplePairingObjects", "SpecExtension". +`type` can be one of the following only: "any", "Root", "Tag", "TagList", "TagGroups", "TagGroup", "ExternalDocs", "Example", "ExamplesMap", "EnumDescriptions", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Logo", "Paths", "PathItem", "Parameter", "ParameterItems", "ParameterList", "Operation", "Examples", "Header", "Responses", "Response", "Schema", "Xml", "SchemaProperties", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", "XCodeSample", "XCodeSampleList", "XServerList", "XServer", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Encoding", "EncodingMap", "HeadersMap", "Link", "DiscriminatorMapping", "Discriminator", "Components", "LinksMap", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "XUsePkce", "WebhooksMap", "PatternProperties", "NamedPathItems", "DependentRequired", "DeviceAuthorization", "NamedMediaTypes", "HttpServerBinding", "HttpChannelBinding", "HttpMessageBinding", "HttpOperationBinding", "WsServerBinding", "WsChannelBinding", "WsMessageBinding", "WsOperationBinding", "KafkaServerBinding", "KafkaTopicConfiguration", "KafkaChannelBinding", "KafkaMessageBinding", "KafkaOperationBinding", "AnypointmqServerBinding", "AnypointmqChannelBinding", "AnypointmqMessageBinding", "AnypointmqOperationBinding", "AmqpServerBinding", "AmqpChannelBinding", "AmqpMessageBinding", "AmqpOperationBinding", "Amqp1ServerBinding", "Amqp1ChannelBinding", "Amqp1MessageBinding", "Amqp1OperationBinding", "MqttServerBindingLastWill", "MqttServerBinding", "MqttChannelBinding", "MqttMessageBinding", "MqttOperationBinding", "Mqtt5ServerBinding", "Mqtt5ChannelBinding", "Mqtt5MessageBinding", "Mqtt5OperationBinding", "NatsServerBinding", "NatsChannelBinding", "NatsMessageBinding", "NatsOperationBinding", "JmsServerBinding", "JmsChannelBinding", "JmsMessageBinding", "JmsOperationBinding", "SolaceServerBinding", "SolaceChannelBinding", "SolaceMessageBinding", "SolaceDestination", "SolaceOperationBinding", "StompServerBinding", "StompChannelBinding", "StompMessageBinding", "StompOperationBinding", "RedisServerBinding", "RedisChannelBinding", "RedisMessageBinding", "RedisOperationBinding", "MercureServerBinding", "MercureChannelBinding", "MercureMessageBinding", "MercureOperationBinding", "ServerBindings", "ChannelBindings", "MessageBindings", "OperationBindings", "ServerMap", "ChannelMap", "Channel", "ParametersMap", "MessageExample", "NamedMessages", "NamedMessageTraits", "NamedOperationTraits", "NamedCorrelationIds", "SecuritySchemeFlows", "Message", "OperationTrait", "OperationTraitList", "MessageTrait", "MessageTraitList", "MessageExampleList", "CorrelationId", "Dependencies", "OperationReply", "OperationReplyAddress", "NamedTags", "NamedExternalDocs", "NamedChannels", "NamedOperations", "NamedOperationReplies", "NamedOperationRelyAddresses", "SecuritySchemeList", "MessageList", "SourceDescriptions", "OpenAPISourceDescription", "ArazzoSourceDescription", "Parameters", "ActionParameters", "ActionParameter", "ReusableObject", "Workflows", "Workflow", "Steps", "Step", "Replacement", "ExtendedOperation", "ExtendedSecurityList", "ExtendedSecurity", "Outputs", "CriterionObject", "XPathCriterion", "JSONPathCriterion", "SuccessActionObject", "OnSuccessActionList", "FailureActionObject", "OnFailureActionList", "NamedInputs", "NamedSuccessActions", "NamedFailureActions", "Actions", "Action", "Method", "MethodList", "ContentDescriptor", "ContentDescriptorList", "ExamplePairing", "ExamplePairingList", "ExampleList", "LinkList", "ErrorObject", "ErrorList", "NamedContentDescriptors", "NamedErrors", "NamedExamplePairingObjects", "SpecExtension". 3 | where: 4 | - subject: