From 6b7e5f3813e5eba1ba60d2a1651dd39be32af31e Mon Sep 17 00:00:00 2001 From: Sundram Date: Tue, 26 May 2026 21:06:38 +0530 Subject: [PATCH] fix(app): null-safe OAuth2 scope in OpenAPI export (BRU-3297) (#8086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * chore: drop internal scope-discussion comment in openapi-spec test Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/utils/exporters/openapi-spec.js | 28 ++------ .../src/utils/exporters/openapi-spec.spec.js | 65 +++++++++++++++++++ 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/packages/bruno-app/src/utils/exporters/openapi-spec.js b/packages/bruno-app/src/utils/exporters/openapi-spec.js index 4e9802056..d9ed25e0e 100644 --- a/packages/bruno-app/src/utils/exporters/openapi-spec.js +++ b/packages/bruno-app/src/utils/exporters/openapi-spec.js @@ -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] = { diff --git a/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js b/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js index 8ac0f58ac..87f668e20 100644 --- a/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js +++ b/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js @@ -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:'); + }); + }); +});