Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
88ef47b
feat: added OAuth2 support and enhance security parameter handling
harshit078 May 19, 2026
02abd75
Merge branch 'main' into Add-support-for-clientCredential
harshit078 May 22, 2026
590d9b8
feat: extended OAuth2 support by adding token exchange
harshit078 May 22, 2026
b834cf2
feat: added Oath2 validation tests
harshit078 May 22, 2026
789afed
feat: added security validation async3
harshit078 May 22, 2026
c59d0a4
Merge branch 'main' into Add-support-for-clientCredential
harshit078 May 22, 2026
978d836
feat: added OAuth2 token and password flow
harshit078 May 25, 2026
ef3b37c
feat: added test for oauth2
harshit078 May 26, 2026
8528b1f
Merge branch 'main' into Add-support-for-clientCredential
harshit078 May 26, 2026
9978d3b
feat: added changeset
harshit078 May 26, 2026
9de2521
fix: failing vale failing test
harshit078 May 26, 2026
e06b3a0
fix: comments addressed by cursor
harshit078 Jun 2, 2026
a8fc9a8
Merge branch 'main' into Add-support-for-clientCredential
harshit078 Jun 2, 2026
0dfb96d
fix: failing lint test
harshit078 Jun 2, 2026
cda6f48
fix: comment left by cursor
harshit078 Jun 2, 2026
9ef677b
fix: failing lint test
harshit078 Jun 2, 2026
637545a
fix: failing lint test
harshit078 Jun 2, 2026
25a0c58
Merge branch 'main' into Add-support-for-clientCredential
harshit078 Jun 8, 2026
3978e59
fix: address cursor comments
harshit078 Jun 9, 2026
120e465
fix: addrress cursor bot comment
harshit078 Jun 9, 2026
a160588
Merge branch 'main' into Add-support-for-clientCredential
harshit078 Jun 9, 2026
a98fd8c
fix: addrress cursor bot comment
harshit078 Jun 9, 2026
912da7e
Merge branch 'main' into Add-support-for-clientCredential
harshit078 Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/oauth2-x-security-token-exchange.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -527,4 +527,229 @@ 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(`[]`);
});
});
84 changes: 84 additions & 0 deletions packages/core/src/rules/async3/security-defined.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { isRef } from '../../ref-utils.js';
import 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<string>();
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);
}
},
};
};
Comment thread
harshit078 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -8,12 +9,27 @@ 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<string, unknown> | undefined
): readonly string[] {
if (values && 'accessToken' in values) {
return ['accessToken'];
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (flows?.clientCredentials) {
return ['clientId', 'clientSecret'];
}
if (flows?.password) {
return ['username', 'password'];
}
return ['accessToken'];
Comment thread
cursor[bot] marked this conversation as resolved.
}

function validateSecuritySchemas(
extendedSecurity: ExtendedSecurity[] | undefined,
Expand All @@ -31,7 +47,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(
Expand All @@ -40,7 +56,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<string, unknown>)
: REQUIRED_VALUES_BY_AUTH_TYPE[authType as keyof typeof REQUIRED_VALUES_BY_AUTH_TYPE];

if (requiredValues) {
for (const requiredValue of requiredValues) {
Expand Down
Loading
Loading