diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 08a657144..0fa88640a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,6 +58,8 @@ jobs: run: npm run test --workspace=packages/bruno-converters - name: Test Package bruno-electron run: npm run test --workspace=packages/bruno-electron + - name: Test Package bruno-requests + run: npm run test --workspace=packages/bruno-requests cli-test: name: CLI Tests diff --git a/.gitignore b/.gitignore index d2f68f452..fb650b306 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ yarn-error.log* bruno.iml .idea .vscode +.cursor # Playwright /blob-report/ diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js index 51d21fce2..5ac61783f 100644 --- a/packages/bruno-electron/src/utils/oauth2.js +++ b/packages/bruno-electron/src/utils/oauth2.js @@ -254,7 +254,8 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo 'Accept': 'application/json' }; if (credentialsPlacement === 'basic_auth_header') { - axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + const secret = clientSecret ?? ''; + axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } const data = { @@ -458,8 +459,9 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo 'content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }; - if (credentialsPlacement === 'basic_auth_header' && clientSecret && clientSecret.trim() !== '') { - axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + if (credentialsPlacement === 'basic_auth_header') { + const secret = clientSecret ?? ''; + axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } const data = { grant_type: 'client_credentials' @@ -605,8 +607,9 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, 'content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }; - if (credentialsPlacement === 'basic_auth_header' && clientSecret && clientSecret.trim() !== '') { - axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + if (credentialsPlacement === 'basic_auth_header') { + const secret = clientSecret ?? ''; + axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } const data = { grant_type: 'password', @@ -667,7 +670,8 @@ const refreshOauth2Token = async ({ requestCopy, collectionUid, certsAndProxyCon 'Accept': 'application/json' }; if (credentialsPlacement === 'basic_auth_header') { - axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret || ''}`).toString('base64')}`; + const secret = clientSecret ?? ''; + axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } axiosRequestConfig.url = url; axiosRequestConfig.responseType = 'arraybuffer'; diff --git a/packages/bruno-requests/src/auth/oauth2-helper.spec.ts b/packages/bruno-requests/src/auth/oauth2-helper.spec.ts new file mode 100644 index 000000000..7cfd96b47 --- /dev/null +++ b/packages/bruno-requests/src/auth/oauth2-helper.spec.ts @@ -0,0 +1,475 @@ +import axios from 'axios'; +import { getOAuth2Token, TokenStore, OAuth2Config } from './oauth2-helper'; + +/** + * Creates a mock token store for testing purposes. + * + * The token store simulates credential persistence using an in-memory Map. + * Keys are formatted as `${url}:${credentialsId}` to uniquely identify credentials. + */ +const createMockTokenStore = (): TokenStore & { credentials: Map } => { + const credentials = new Map(); + return { + credentials, + async saveCredential({ url, credentialsId, credentials: creds }) { + credentials.set(`${url}:${credentialsId}`, creds); + return true; + }, + async getCredential({ url, credentialsId }) { + return credentials.get(`${url}:${credentialsId}`) || null; + }, + async deleteCredential({ url, credentialsId }) { + return credentials.delete(`${url}:${credentialsId}`); + } + }; +}; + +/** + * Creates a mock axios adapter that intercepts HTTP requests. + * + * This allows tests to: + * 1. Capture the request config (headers, body, URL) for assertion + * 2. Return a controlled response without making actual network calls + * + * @param responseData - The mock response data to return (defaults to a valid token response) + * @returns An object containing the adapter and a getter for the captured request config + */ +const createMockAdapter = (responseData: any = { access_token: 'test-token', expires_in: 3600 }) => { + let capturedConfig: any = null; + + const adapter = async (config: any) => { + capturedConfig = config; + return { + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + config, + data: Buffer.from(JSON.stringify(responseData)) + }; + }; + + return { adapter, getCapturedConfig: () => capturedConfig }; +}; + +/** + * OAuth2 Client Credentials Grant Tests + * + * These tests verify the behavior of the OAuth2 client credentials flow, + * specifically focusing on how client credentials (clientId and clientSecret) + * are transmitted to the authorization server. + * + * OAuth2 spec allows two methods for sending client credentials: + * 1. HTTP Basic Authentication header (RFC 6749 Section 2.3.1) + * 2. Request body parameters (RFC 6749 Section 2.3.1) + * + * The `credentialsPlacement` config option controls which method is used. + */ +describe('OAuth2 Helper - Client Credentials Grant', () => { + let originalAdapter: any; + + beforeEach(() => { + originalAdapter = axios.defaults.adapter; + }); + + afterEach(() => { + axios.defaults.adapter = originalAdapter; + }); + + /** + * Tests for `credentialsPlacement: 'basic_auth_header'` + * + * When using Basic Auth, credentials are sent as: + * Authorization: Basic base64(clientId:clientSecret) + * + * Per RFC 6749, even if clientSecret is empty, the colon separator + * must still be present: base64(clientId:) + */ + describe('when credentialsPlacement is basic_auth_header', () => { + /** + * Verifies that when clientSecret is undefined, we still send a valid + * Authorization header with an empty secret (clientId:) + * + * This handles cases where a public client doesn't have a secret + * but the server still expects Basic Auth format. + */ + test('should send token request with Authorization header when clientSecret is undefined', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: undefined, + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Authorization header should contain base64(clientId:) with empty secret + // "my-client-id:" encodes to "bXktY2xpZW50LWlkOg==" + const expectedAuth = `Basic ${Buffer.from('my-client-id:').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + + // grant_type must always be in the request body + expect(capturedConfig.data).toContain('grant_type=client_credentials'); + + // When using basic_auth_header, client_id should NOT be duplicated in the body + expect(capturedConfig.data).not.toContain('client_id='); + }); + + /** + * Verifies that an empty string clientSecret is treated the same as undefined. + * + * The implementation uses nullish coalescing (clientSecret ?? '') so both + * undefined and empty string result in the same Authorization header. + */ + test('should send token request with Authorization header when clientSecret is empty string', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: '', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Empty string secret should produce same result as undefined + const expectedAuth = `Basic ${Buffer.from('my-client-id:').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + }); + + /** + * Verifies that when clientSecret is provided, it's properly included + * in the Authorization header. + */ + test('should send token request with Authorization header when clientSecret is present', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: 'my-secret', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Authorization header should contain base64(clientId:clientSecret) + // "my-client-id:my-secret" encodes to "bXktY2xpZW50LWlkOm15LXNlY3JldA==" + const expectedAuth = `Basic ${Buffer.from('my-client-id:my-secret').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + + // When using basic_auth_header, client_secret should NOT be in the body + expect(capturedConfig.data).not.toContain('client_secret='); + }); + }); + + /** + * Tests for `credentialsPlacement: 'body'` + * + * When using body placement, credentials are sent as form parameters: + * client_id=xxx&client_secret=yyy + * + * No Authorization header should be present. + */ + describe('when credentialsPlacement is body', () => { + /** + * Verifies that when clientSecret is empty, only client_id is sent in the body. + * + * An empty client_secret should not be sent as it may cause issues with + * some authorization servers that interpret it differently than omitting it. + */ + test('should send client_id in body and no Authorization header when clientSecret is empty', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: '', + credentialsPlacement: 'body' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // No Authorization header when using body placement + expect(capturedConfig.headers['Authorization']).toBeUndefined(); + + // client_id must be in the body + expect(capturedConfig.data).toContain('client_id=my-client-id'); + + // Empty client_secret should be omitted entirely, not sent as empty value + expect(capturedConfig.data).not.toContain('client_secret='); + }); + + /** + * Verifies that when clientSecret is provided, both client_id and + * client_secret are sent in the request body. + */ + test('should send both client_id and client_secret in body when clientSecret is present', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: 'my-secret', + credentialsPlacement: 'body' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // No Authorization header when using body placement + expect(capturedConfig.headers['Authorization']).toBeUndefined(); + + // Both credentials should be in the body + expect(capturedConfig.data).toContain('client_id=my-client-id'); + expect(capturedConfig.data).toContain('client_secret=my-secret'); + }); + }); +}); + +/** + * OAuth2 Password Grant Tests (Resource Owner Password Credentials) + * + * These tests verify the password grant flow, which includes: + * - User credentials (username, password) always sent in the body + * - Client credentials (clientId, clientSecret) placement configurable + * + * Note: Password grant is considered legacy and not recommended for new apps, + * but many existing systems still require it. + */ +describe('OAuth2 Helper - Password Grant', () => { + let originalAdapter: any; + + beforeEach(() => { + originalAdapter = axios.defaults.adapter; + }); + + afterEach(() => { + axios.defaults.adapter = originalAdapter; + }); + + /** + * Tests for `credentialsPlacement: 'basic_auth_header'` with password grant + * + * Client credentials go in Authorization header, while user credentials + * (username, password) are always in the request body. + */ + describe('when credentialsPlacement is basic_auth_header', () => { + /** + * Verifies password grant with undefined clientSecret sends proper + * Authorization header and includes username/password in body. + */ + test('should send token request with Authorization header when clientSecret is undefined', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: undefined, + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Authorization header with empty secret + const expectedAuth = `Basic ${Buffer.from('my-client-id:').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + + // Password grant specific: grant_type and user credentials in body + expect(capturedConfig.data).toContain('grant_type=password'); + expect(capturedConfig.data).toContain('username=testuser'); + expect(capturedConfig.data).toContain('password=testpass'); + + // client_id should NOT be in body when using basic_auth_header + expect(capturedConfig.data).not.toContain('client_id='); + }); + + /** + * Verifies empty string clientSecret behaves same as undefined. + */ + test('should send token request with Authorization header when clientSecret is empty string', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: '', + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Empty string treated same as undefined + const expectedAuth = `Basic ${Buffer.from('my-client-id:').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + }); + + /** + * Verifies clientSecret is properly included in Authorization header. + */ + test('should send token request with Authorization header when clientSecret is present', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: 'my-secret', + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Full credentials in Authorization header + const expectedAuth = `Basic ${Buffer.from('my-client-id:my-secret').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + + // client_secret should NOT be duplicated in body + expect(capturedConfig.data).not.toContain('client_secret='); + }); + }); + + /** + * Tests for `credentialsPlacement: 'body'` with password grant + * + * Both client credentials and user credentials are sent in the request body. + */ + describe('when credentialsPlacement is body', () => { + /** + * Verifies password grant with empty clientSecret sends client_id + * but omits client_secret from the body. + */ + test('should send client_id in body and no Authorization header when clientSecret is empty', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: '', + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'body' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // No Authorization header + expect(capturedConfig.headers['Authorization']).toBeUndefined(); + + // client_id in body, but not empty client_secret + expect(capturedConfig.data).toContain('client_id=my-client-id'); + expect(capturedConfig.data).not.toContain('client_secret='); + }); + + /** + * Verifies password grant with clientSecret sends all credentials in body. + */ + test('should send both client_id and client_secret in body when clientSecret is present', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: 'my-secret', + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'body' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // No Authorization header + expect(capturedConfig.headers['Authorization']).toBeUndefined(); + + // All credentials in body + expect(capturedConfig.data).toContain('client_id=my-client-id'); + expect(capturedConfig.data).toContain('client_secret=my-secret'); + }); + }); +}); diff --git a/packages/bruno-requests/src/auth/oauth2-helper.ts b/packages/bruno-requests/src/auth/oauth2-helper.ts index be3fc1457..98ec99bd8 100644 --- a/packages/bruno-requests/src/auth/oauth2-helper.ts +++ b/packages/bruno-requests/src/auth/oauth2-helper.ts @@ -145,7 +145,8 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => { } if (credentialsPlacement === 'basic_auth_header') { - requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret!}`).toString('base64')}`; + const secret = clientSecret ?? ''; + requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } if (credentialsPlacement !== 'basic_auth_header') { @@ -246,7 +247,8 @@ const fetchTokenPassword = async (oauth2Config: OAuth2Config) => { } if (credentialsPlacement === 'basic_auth_header') { - requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret!}`).toString('base64')}`; + const secret = clientSecret ?? ''; + requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } if (credentialsPlacement !== 'basic_auth_header') {