Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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);
}
},
};
};
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'];
}
if (flows?.clientCredentials) {
return ['clientId', 'clientSecret'];
}
if (flows?.password) {
return ['username', 'password'];
}
return ['accessToken'];
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -38,7 +38,7 @@ describe('resolveXSecurityParameters', () => {
],
} as unknown as Step;

const parameters = resolveXSecurityParameters({
const parameters = await resolveXSecurityParameters({
ctx,
runtimeContext,
step,
Expand All @@ -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: {
Expand Down Expand Up @@ -135,7 +135,7 @@ describe('resolveXSecurityParameters', () => {
securitySchemes: { MuseumPlaceholderAuth: { type: 'http', scheme: 'basic' } },
} as any;

const parameters = resolveXSecurityParameters({
const parameters = await resolveXSecurityParameters({
ctx,
runtimeContext,
step,
Expand Down Expand Up @@ -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<string>(),
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<string>(),
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',
Expand All @@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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' };
Expand Down
Loading
Loading