fix(app): null-safe OAuth2 scope in OpenAPI export (BRU-3297) (#8086)

* fix(app): null-safe OAuth2 scope in OpenAPI export (BRU-3297)

The OpenAPI exporter calls `.length` on `auth.oauth2.scope` without a
null guard. When a user never fills the Scope field for an OAuth2 auth
(grant types authorization_code, password, client_credentials), Bruno
stores `scope` as `null`, causing the entire export to crash with
`TypeError: Cannot read properties of null (reading 'length')`.

Replace the unsafe `scope.length > 0` check with a truthy check that
handles null, undefined, and empty string uniformly. Emit `scopes` as
an empty object when no scope is set — OpenAPI 3.0 requires `scopes`
to be present on every OAuth2 flow even when empty.

Add 12 jest tests covering all 3 affected grant types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: wrap oauth2 case in block to scope local declarations

Addresses Biome `noSwitchDeclarations` — `const` declarations inside a
case clause without braces can leak across cases. Pure cosmetic; no
behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: drop internal scope-discussion comment in openapi-spec test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sundram
2026-05-26 21:06:38 +05:30
committed by GitHub
parent d9c13e74ac
commit 6b7e5f3813
2 changed files with 71 additions and 22 deletions

View File

@@ -390,10 +390,11 @@ export const exportApiSpec = ({ variables, items, name, environments }) => {
[componentId]: []
};
break;
case 'oauth2':
case 'oauth2': {
if (!auth?.oauth2?.grantType) break;
componentId = getItemComponentId();
const { authorizationUrl, accessTokenUrl, callbackUrl, scope } = auth?.oauth2;
const scopes = scope ? { [scope]: '' } : {};
switch (auth?.oauth2?.grantType) {
case 'authorization_code':
components.securitySchemes[componentId] = {
@@ -402,13 +403,7 @@ export const exportApiSpec = ({ variables, items, name, environments }) => {
authorizationCode: {
authorizationUrl,
tokenUrl: accessTokenUrl,
...(scope.length > 0
? {
scopes: {
[scope]: ''
}
}
: {})
scopes
}
}
};
@@ -422,13 +417,7 @@ export const exportApiSpec = ({ variables, items, name, environments }) => {
flows: {
password: {
tokenUrl: accessTokenUrl,
...(scope.length > 0
? {
scopes: {
[scope]: ''
}
}
: {})
scopes
}
}
};
@@ -442,13 +431,7 @@ export const exportApiSpec = ({ variables, items, name, environments }) => {
flows: {
password: {
tokenUrl: accessTokenUrl,
...(scope.length > 0
? {
scopes: {
[scope]: ''
}
}
: {})
scopes
}
}
};
@@ -458,6 +441,7 @@ export const exportApiSpec = ({ variables, items, name, environments }) => {
break;
}
break;
}
case 'awsv4':
componentId = getItemComponentId();
components.securitySchemes[componentId] = {

View File

@@ -816,3 +816,68 @@ describe('exportApiSpec - multi-environment servers', () => {
expect(content).toContain('description: Staging');
});
});
describe('exportApiSpec - OAuth2 scope handling (BRU-3297)', () => {
const makeOauth2Item = (grantType, scope) => ({
name: 'Req',
type: 'http-request',
request: {
url: 'https://api.example.com/users',
method: 'GET',
params: [],
headers: [],
body: {},
auth: {
mode: 'oauth2',
oauth2: {
grantType,
authorizationUrl: 'https://auth.example.com/authorize',
accessTokenUrl: 'https://auth.example.com/token',
callbackUrl: 'https://app.example.com/callback',
scope
}
}
}
});
// No-throw checks for all 3 grant types affected by BRU-3297.
describe.each([
'authorization_code',
'password',
'client_credentials'
])('grant type %s', (grantType) => {
it(`should not throw when scope is null`, () => {
const items = [makeOauth2Item(grantType, null)];
expect(() => exportApiSpec({ variables: {}, items, name: 'Test' })).not.toThrow();
});
it(`should not throw when scope is undefined`, () => {
const items = [makeOauth2Item(grantType, undefined)];
expect(() => exportApiSpec({ variables: {}, items, name: 'Test' })).not.toThrow();
});
});
describe.each([
['authorization_code', 'authorizationCode'],
['password', 'password']
])('grant type %s emits valid scopes object', (grantType, flowKey) => {
it(`should emit empty scopes object when scope is null (OpenAPI 3.0 requires scopes key)`, () => {
const items = [makeOauth2Item(grantType, null)];
const { content } = exportApiSpec({ variables: {}, items, name: 'Test' });
expect(content).toContain(`${flowKey}:`);
expect(content).toMatch(new RegExp(`${flowKey}:[\\s\\S]*?scopes:\\s*{}`));
});
it(`should emit empty scopes object when scope is empty string`, () => {
const items = [makeOauth2Item(grantType, '')];
const { content } = exportApiSpec({ variables: {}, items, name: 'Test' });
expect(content).toMatch(new RegExp(`${flowKey}:[\\s\\S]*?scopes:\\s*{}`));
});
it(`should emit scope entry when scope is a non-empty string`, () => {
const items = [makeOauth2Item(grantType, 'openid')];
const { content } = exportApiSpec({ variables: {}, items, name: 'Test' });
expect(content).toContain('openid:');
});
});
});