feat: use default browser for oauth2 authorization bru-2167 (#6101)

* feat: use default browser for oauth2 authorization bru-2167

* fix: coderabbit review comment fixes

* fix: coderabbit review comment fixes

* fix: protocol registration updates

* fix: coderabbit review comment suggestions

* fix: oauth2 auth form use system browser option
This commit is contained in:
lohit
2025-12-16 17:23:49 +05:30
committed by GitHub
parent dbd966850c
commit 231776ca4b
17 changed files with 610 additions and 69 deletions

View File

@@ -56,6 +56,9 @@ const General = ({ close }) => {
}
return true;
}),
oauth2: Yup.object({
useSystemBrowser: Yup.boolean()
}),
defaultCollectionLocation: Yup.string().max(1024)
});
@@ -76,6 +79,9 @@ const General = ({ close }) => {
enabled: get(preferences, 'autoSave.enabled', false),
interval: get(preferences, 'autoSave.interval', 1000)
},
oauth2: {
useSystemBrowser: get(preferences, 'request.oauth2.useSystemBrowser', false)
},
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
},
validationSchema: preferencesSchema,
@@ -104,7 +110,10 @@ const General = ({ close }) => {
},
timeout: newPreferences.timeout,
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies
sendCookies: newPreferences.sendCookies,
oauth2: {
useSystemBrowser: newPreferences.oauth2.useSystemBrowser
}
},
autoSave: {
enabled: newPreferences.autoSave.enabled,
@@ -258,6 +267,19 @@ const General = ({ close }) => {
Send Cookies automatically
</label>
</div>
<div className="flex items-center mt-2">
<input
id="oauth2.useSystemBrowser"
type="checkbox"
name="oauth2.useSystemBrowser"
checked={formik.values.oauth2.useSystemBrowser}
onChange={formik.handleChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="oauth2.useSystemBrowser">
Use System Browser for OAuth2 Authorization
</label>
</div>
<div className="flex flex-col mt-6">
<label className="block select-none" htmlFor="timeout">
Request Timeout (in ms)

View File

@@ -2,7 +2,7 @@ import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
@@ -12,10 +12,14 @@ import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const { storedTheme } = useTheme();
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
@@ -122,6 +126,29 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
);
};
const handleUseSystemBrowserToggle = (e) => {
const newValue = e.target.checked;
dispatch(
savePreferences({
...preferences,
request: {
...preferences.request,
oauth2: {
...preferences.request.oauth2,
useSystemBrowser: newValue
}
}
})
)
.then(() => {
toast.success('Preference updated successfully');
})
.catch((err) => {
console.error(err);
toast.error('Failed to update preference');
});
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
@@ -133,6 +160,43 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
Configuration
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-callbackUrl">
<label className="block min-w-[140px]">Callback URL</label>
<div className="flex flex-col gap-1 w-full">
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={callbackUrl}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('callbackUrl', val)}
onRun={handleRun}
collection={collection}
item={item}
placeholder={useSystemBrowser ? 'https://oauth2.usebruno.com/callback' : undefined}
/>
</div>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-use-system-browser">
<label className="block min-w-[140px]"></label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(useSystemBrowser)}
onChange={handleUseSystemBrowserToggle}
className="cursor-pointer"
/>
<label
className="block cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleUseSystemBrowserToggle({ target: { checked: !useSystemBrowser } });
}}
>
Use system browser for OAuth
</label>
</div>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
const value = oAuth[key] || '';

View File

@@ -1,8 +1,4 @@
const inputsConfig = [
{
key: 'callbackUrl',
label: 'Callback URL'
},
{
key: 'authorizationUrl',
label: 'Authorization URL'

View File

@@ -1,7 +1,7 @@
import React, { useRef, forwardRef, useMemo } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
@@ -12,9 +12,13 @@ import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import { getAllVariables } from 'utils/collections/index';
import { interpolate } from '@usebruno/common';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
@@ -77,6 +81,29 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
handleChange('autoFetchToken', e.target.checked);
};
const handleUseSystemBrowserToggle = (e) => {
const newValue = e.target.checked;
dispatch(
savePreferences({
...preferences,
request: {
...preferences.request,
oauth2: {
...preferences.request.oauth2,
useSystemBrowser: newValue
}
}
})
)
.then(() => {
toast.success('Preference updated successfully');
})
.catch((err) => {
console.error(err);
toast.error('Failed to update preference');
});
};
return (
<Wrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={authorizationUrl} credentialsId={credentialsId} />
@@ -88,6 +115,43 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
Configuration
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-callbackUrl">
<label className="block min-w-[140px]">Callback URL</label>
<div className="flex flex-col gap-1 w-full">
<div className="oauth2-input-wrapper flex-1 flex items-center">
<SingleLineEditor
value={callbackUrl}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('callbackUrl', val)}
onRun={handleRun}
collection={collection}
item={item}
placeholder={useSystemBrowser ? 'https://oauth2.usebruno.com/callback' : undefined}
/>
</div>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-use-system-browser">
<label className="block min-w-[140px]"></label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(useSystemBrowser)}
onChange={handleUseSystemBrowserToggle}
className="cursor-pointer"
/>
<label
className="block cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleUseSystemBrowserToggle({ target: { checked: !useSystemBrowser } });
}}
>
Use system browser for OAuth
</label>
</div>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (

View File

@@ -1,8 +1,4 @@
const inputsConfig = [
{
key: 'callbackUrl',
label: 'Callback URL'
},
{
key: 'authorizationUrl',
label: 'Authorization URL'

View File

@@ -1,18 +1,36 @@
import { useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useMemo, useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { cloneDeep, find } from 'lodash';
import { IconLoader2 } from '@tabler/icons';
import { cloneDeep, find, get } from 'lodash';
import { IconLoader2, IconX } from '@tabler/icons';
import { interpolate } from '@usebruno/common';
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials, cancelOauth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } from 'providers/ReduxStore/slices/collections/actions';
import { getAllVariables } from 'utils/collections/index';
const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, credentialsId }) => {
const { uid: collectionUid } = collection;
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const [fetchingToken, toggleFetchingToken] = useState(false);
const [refreshingToken, toggleRefreshingToken] = useState(false);
const [fetchingAuthorizationCode, toggleFetchingAuthorizationCode] = useState(false);
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
// Check for pending authorization when component mounts or when fetching starts
useEffect(() => {
if (useSystemBrowser && fetchingToken) {
const getRequestStatus = async () => {
try {
toggleFetchingAuthorizationCode(await dispatch(isOauth2AuthorizationRequestInProgress()));
} catch (err) {
console.error('Error checking pending authorization:', err);
}
};
getRequestStatus();
}
}, [useSystemBrowser, fetchingToken, dispatch]);
const interpolatedAccessTokenUrl = useMemo(() => {
const variables = getAllVariables(collection, item);
@@ -35,8 +53,6 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
forceGetToken: true
}));
toggleFetchingToken(false);
// Check if the result contains error or if access_token is missing
if (!result || !result.access_token) {
const errorMessage = result?.error || 'No access token received from authorization server';
@@ -49,8 +65,14 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
} catch (error) {
console.error('could not fetch the token!');
console.error(error);
toggleFetchingToken(false);
// Don't show error toast for user cancellation
if (error?.message && error.message.includes('cancelled by user')) {
return;
}
toast.error(error?.message || 'An error occurred while fetching token!');
} finally {
toggleFetchingToken(false);
toggleFetchingAuthorizationCode(false);
}
};
@@ -95,6 +117,20 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
});
};
const handleCancelAuthorization = async () => {
try {
const result = await dispatch(cancelOauth2AuthorizationRequest());
if (result.success && result.cancelled) {
toast.error('Authorization cancelled');
toggleFetchingToken(false);
toggleFetchingAuthorizationCode(false);
}
} catch (err) {
console.error('Error cancelling authorization:', err);
toast.error('Failed to cancel authorization');
}
};
return (
<div className="flex flex-row gap-4 mt-4">
<button
@@ -115,6 +151,16 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
</button>
)
: null}
{useSystemBrowser && fetchingAuthorizationCode
? (
<button
onClick={handleCancelAuthorization}
className="submit btn btn-sm btn-secondary w-fit flex flex-row items-center"
>
<IconX size={16} className="mr-1" />
Cancel Authorization
</button>
) : null}
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>

View File

@@ -186,6 +186,9 @@ class SingleLineEditor extends Component {
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.placeholder !== prevProps.placeholder && this.editor) {
this.editor.setOption('placeholder', this.props.placeholder);
}
this.ignoreChangeEvent = false;
}

View File

@@ -23,7 +23,10 @@ const initialState = {
keepDefaultCaCertificates: {
enabled: true
},
timeout: 0
timeout: 0,
oauth2: {
useSystemBrowser: false
}
},
font: {
codeFont: 'default'

View File

@@ -2530,6 +2530,24 @@ export const clearOauth2Cache = (payload) => async (dispatch, getState) => {
});
};
export const isOauth2AuthorizationRequestInProgress = () => async () => {
return new Promise((resolve, reject) => {
window.ipcRenderer
.invoke('renderer:is-oauth2-authorization-request-in-progress')
.then(resolve)
.catch(reject);
});
};
export const cancelOauth2AuthorizationRequest = () => async () => {
return new Promise((resolve, reject) => {
window.ipcRenderer
.invoke('renderer:cancel-oauth2-authorization-request')
.then(resolve)
.catch(reject);
});
};
// todo: could be removed
export const loadRequestViaWorker
= ({ collectionUid, pathname }) =>

View File

@@ -33,12 +33,44 @@ const config = {
hardenedRuntime: true,
identity: 'Anoop MD (W7LPPWA48L)',
entitlements: 'resources/entitlements.mac.plist',
entitlementsInherit: 'resources/entitlements.mac.plist'
entitlementsInherit: 'resources/entitlements.mac.plist',
notarize: false,
protocols: [
{
name: 'Bruno',
schemes: [
'bruno'
]
}
]
},
linux: {
artifactName: '${name}_${version}_${arch}_linux.${ext}',
artifactName: '${name}_${version}_${arch}_${os}.${ext}',
icon: 'resources/icons/png',
target: ['AppImage', 'deb', 'snap', 'rpm']
target: [
{
target: 'AppImage',
arch: ['x64', 'arm64']
},
{
target: 'deb',
arch: ['x64', 'arm64']
},
{
target: 'rpm',
arch: ['x64', 'arm64']
}
],
protocols: [
{
name: 'Bruno',
schemes: ['bruno']
}
],
category: 'Development',
desktop: {
MimeType: 'x-scheme-handler/bruno;'
}
},
deb: {
// Docs: https://www.electron.build/configuration/linux#debian-package-options

View File

@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('node:child_process');
const isDev = require('electron-is-dev');
const os = require('os');
@@ -54,6 +55,7 @@ const { cookiesStore } = require('./store/cookies');
const onboardUser = require('./app/onboarding');
const SystemMonitor = require('./app/system-monitor');
const { getIsRunningInRosetta } = require('./utils/arch');
const { handleAppProtocolUrl, getAppProtocolUrlFromArgv } = require('./utils/deeplink');
const lastOpenedCollections = new LastOpenedCollections();
const systemMonitor = new SystemMonitor();
@@ -85,6 +87,35 @@ const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
let mainWindow;
let appProtocolUrl;
// Register custom protocol handler (must be called before app is ready)
// In dev mode, we need to pass the Electron executable path and script path
app.setAsDefaultProtocolClient('bruno');
if (os.platform() === 'linux') {
try {
execSync('xdg-mime default bruno.desktop x-scheme-handler/bruno');
} catch (err) {}
}
appProtocolUrl = getAppProtocolUrlFromArgv(process.argv);
// Handle protocol URLs (macOS)
if (process.platform === 'darwin') {
app.on('open-url', (event, url) => {
event.preventDefault();
appProtocolUrl = url || appProtocolUrl;
handleAppProtocolUrl(appProtocolUrl, mainWindow);
});
}
// Handle protocol URLs when app is already running (Windows/Linux)
if (process.platform === 'win32' || process.platform === 'linux') {
app.on('second-instance', (event, argv) => {
appProtocolUrl = getAppProtocolUrlFromArgv(argv) || appProtocolUrl;
handleAppProtocolUrl(appProtocolUrl, mainWindow);
});
}
// Prepare the renderer once the app is ready
app.on('ready', async () => {
@@ -197,6 +228,12 @@ app.on('ready', async () => {
}
});
mainWindow.webContents.once('did-finish-load', () => {
if (appProtocolUrl) {
handleAppProtocolUrl(appProtocolUrl, mainWindow);
}
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
try {
const { protocol } = new URL(url);

View File

@@ -64,6 +64,7 @@ const { getCertsAndProxyConfig } = require('./network/cert-utils');
const collectionWatcher = require('../app/collection-watcher');
const { transformBrunoConfigBeforeSave } = require('../utils/transfomBrunoConfig');
const { REQUEST_TYPES } = require('../utils/constants');
const { cancelOAuth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } = require('../utils/oauth2-protocol-handler');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
@@ -1324,6 +1325,52 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
}
});
ipcMain.handle('renderer:refresh-oauth2-credentials', async (event, { itemUid, request, collection }) => {
try {
if (request.oauth2) {
let requestCopy = _.cloneDeep(request);
const { uid: collectionUid, pathname: collectionPath, runtimeVariables, environments = [], activeEnvironmentUid } = collection;
const environment = _.find(environments, (e) => e.uid === activeEnvironmentUid);
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
const partialItem = { uid: itemUid };
const requestTreePath = getTreePathFromCollectionToItem(collection, partialItem);
mergeVars(collection, requestCopy, requestTreePath);
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
const globalEnvironmentVariables = collection.globalEnvironmentVariables;
const certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid,
collection,
request: requestCopy,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
let { credentials, url, credentialsId, debugInfo } = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
return { credentials, url, collectionUid, credentialsId, debugInfo };
}
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:cancel-oauth2-authorization-request', async () => {
try {
const cancelled = cancelOAuth2AuthorizationRequest();
return { success: true, cancelled };
} catch (err) {
return { success: false, error: err.message };
}
});
ipcMain.handle('renderer:is-oauth2-authorization-request-in-progress', () => {
return isOauth2AuthorizationRequestInProgress();
});
// todo: could be removed
ipcMain.handle('renderer:load-request-via-worker', async (event, { collectionUid, pathname }) => {
let fileStats;
@@ -1374,39 +1421,6 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
}
});
ipcMain.handle('renderer:refresh-oauth2-credentials', async (event, { itemUid, request, collection }) => {
try {
if (request.oauth2) {
let requestCopy = _.cloneDeep(request);
const { uid: collectionUid, pathname: collectionPath, runtimeVariables, environments = [], activeEnvironmentUid } = collection;
const environment = _.find(environments, (e) => e.uid === activeEnvironmentUid);
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
const partialItem = { uid: itemUid };
const requestTreePath = getTreePathFromCollectionToItem(collection, partialItem);
mergeVars(collection, requestCopy, requestTreePath);
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
const globalEnvironmentVariables = collection.globalEnvironmentVariables;
const certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid,
collection,
request: requestCopy,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
let { credentials, url, credentialsId, debugInfo } = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
return { credentials, url, collectionUid, credentialsId, debugInfo };
}
} catch (error) {
return Promise.reject(error);
}
});
// todo: could be removed
ipcMain.handle('renderer:load-request', async (event, { collectionUid, pathname }) => {
let fileStats;

View File

@@ -0,0 +1,63 @@
const { shell } = require('electron');
const { registerOauth2AuthorizationRequest, rejectOauth2AuthorizationRequest } = require('../../utils/oauth2-protocol-handler');
const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = 'authorization_code' }) => {
return new Promise((resolve, reject) => {
// Replace callback URL in authorization URL
const authorizationUrlObj = new URL(authorizeUrl);
authorizationUrlObj.searchParams.set('redirect_uri', callbackUrl);
const modifiedAuthorizeUrl = authorizationUrlObj.toString();
// Set timeout for the request (5 minutes)
const timeout = setTimeout(() => {
rejectOauth2AuthorizationRequest(new Error('Authorization timeout'));
}, 5 * 60 * 1000);
// Wrap resolve/reject to clear timeout and add debugInfo
const debugInfo = {
data: []
};
const authorizationRequest = {
request: {
url: modifiedAuthorizeUrl,
method: 'GET',
headers: {},
error: null
},
response: {
headers: {},
status: null,
statusText: null,
error: null
},
fromCache: false,
completed: false
};
debugInfo.data.push(authorizationRequest);
const wrappedResolve = (value) => {
clearTimeout(timeout);
if (grantType === 'implicit') {
resolve({ implicitTokens: value, debugInfo });
} else {
resolve({ authorizationCode: value, debugInfo });
}
};
const wrappedReject = (error) => {
clearTimeout(timeout);
reject(error);
};
registerOauth2AuthorizationRequest(wrappedResolve, wrappedReject, debugInfo);
// Open system browser
shell.openExternal(modifiedAuthorizeUrl).catch((error) => {
rejectOauth2AuthorizationRequest(error);
});
});
};
module.exports = { authorizeUserInSystemBrowser };

View File

@@ -20,7 +20,10 @@ const defaultPreferences = {
},
storeCookies: true,
sendCookies: true,
timeout: 0
timeout: 0,
oauth2: {
useSystemBrowser: false
}
},
font: {
codeFont: 'default',
@@ -66,7 +69,10 @@ const preferencesSchema = Yup.object().shape({
}),
storeCookies: Yup.boolean(),
sendCookies: Yup.boolean(),
timeout: Yup.number()
timeout: Yup.number(),
oauth2: Yup.object({
useSystemBrowser: Yup.boolean()
})
}),
font: Yup.object().shape({
codeFont: Yup.string().nullable(),
@@ -200,6 +206,9 @@ const preferencesUtil = {
shouldSendCookies: () => {
return get(getPreferences(), 'request.sendCookies', true);
},
shouldUseSystemBrowser: () => {
return get(getPreferences(), 'request.oauth2.useSystemBrowser', false);
},
getResponsePaneOrientation: () => {
return get(getPreferences(), 'layout.responsePaneOrientation', 'horizontal');
},

View File

@@ -0,0 +1,30 @@
const { handleOauth2ProtocolUrl } = require('./oauth2-protocol-handler');
// Store appProtocolUrl - will be handled in the `did-finish-load` event handler
const getAppProtocolUrlFromArgv = (argv) => {
return argv.find((arg) => arg.startsWith('bruno://'));
};
// Handle app protocol URLs
const handleAppProtocolUrl = (url) => {
// Handle OAuth2 callback URLs - `bruno://app/oauth2/callback`
if (isOauth2Url(url)) {
handleOauth2ProtocolUrl(url);
}
return;
};
const isOauth2Url = (url) => {
try {
const urlObj = new URL(url);
if (urlObj.pathname === '/oauth2/callback') {
return true;
}
} catch (error) {
console.error('[Protocol Handler] Error handling protocol URL:', error);
}
return false;
};
module.exports = { handleAppProtocolUrl, getAppProtocolUrlFromArgv };

View File

@@ -0,0 +1,124 @@
let oauth2AuthorizationRequest = null;
const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null) => {
// Cancel any existing pending request
if (oauth2AuthorizationRequest) {
oauth2AuthorizationRequest.reject(new Error('Authorization cancelled: new request started'));
}
oauth2AuthorizationRequest = {
resolve,
reject,
debugInfo,
timestamp: Date.now()
};
};
const isOauth2AuthorizationRequestInProgress = () => {
return oauth2AuthorizationRequest !== null;
};
const resolveOauth2AuthorizationRequest = (data) => {
if (oauth2AuthorizationRequest) {
oauth2AuthorizationRequest.resolve(data);
oauth2AuthorizationRequest = null;
return true;
}
return false;
};
const rejectOauth2AuthorizationRequest = (error) => {
if (oauth2AuthorizationRequest) {
oauth2AuthorizationRequest.reject(error);
oauth2AuthorizationRequest = null;
return true;
}
return false;
};
const cancelOAuth2AuthorizationRequest = () => {
return rejectOauth2AuthorizationRequest(new Error('Authorization cancelled by user'));
};
const handleOauth2ProtocolUrl = (url) => {
try {
const urlObj = new URL(url);
// Add callback URL details to debugInfo if available
if (oauth2AuthorizationRequest?.debugInfo) {
const callbackRequest = {
request: {
url: url,
method: '',
headers: {},
error: null
},
response: {
url: url,
headers: {},
status: '',
statusText: 'BRUNO_OAUTH2_PROTOCOL',
error: null
},
fromCache: false,
completed: true
};
oauth2AuthorizationRequest.debugInfo.data.push(callbackRequest);
}
// Check for errors in query params (authorization code flow) or hash (implicit flow)
const error = urlObj.searchParams.get('error') || (urlObj.hash ? new URLSearchParams(urlObj.hash.substring(1)).get('error') : null);
const errorDescription = urlObj.searchParams.get('error_description') || (urlObj.hash ? new URLSearchParams(urlObj.hash.substring(1)).get('error_description') : null);
if (error) {
const errorData = {
message: 'Authorization Failed!',
error,
errorDescription
};
rejectOauth2AuthorizationRequest(new Error(JSON.stringify(errorData)));
return;
}
// Check if this is an implicit grant (tokens in hash fragment)
if (urlObj.hash) {
const hash = urlObj.hash.substring(1); // Remove the leading #
const hashParams = new URLSearchParams(hash);
const accessToken = hashParams.get('access_token');
if (accessToken) {
// Extract tokens from hash fragment for implicit grant
const implicitTokens = {
access_token: accessToken,
token_type: hashParams.get('token_type'),
expires_in: hashParams.get('expires_in'),
state: hashParams.get('state'),
scope: hashParams.get('scope')
};
resolveOauth2AuthorizationRequest(implicitTokens);
return;
}
}
// Check for authorization code in query params (authorization code flow)
const code = urlObj.searchParams.get('code');
if (code) {
resolveOauth2AuthorizationRequest(code);
return;
}
// No code or access_token found - reject with error
rejectOauth2AuthorizationRequest(new Error('Invalid OAuth2 callback: missing code or access_token'));
} catch (err) {
console.error('Error handling protocol URL:', err);
rejectOauth2AuthorizationRequest(err);
}
};
module.exports = {
registerOauth2AuthorizationRequest,
rejectOauth2AuthorizationRequest,
cancelOAuth2AuthorizationRequest,
isOauth2AuthorizationRequestInProgress,
handleOauth2ProtocolUrl
};

View File

@@ -1,11 +1,15 @@
const { get, cloneDeep, filter } = require('lodash');
const crypto = require('crypto');
const { authorizeUserInWindow } = require('../ipc/network/authorize-user-in-window');
const { authorizeUserInSystemBrowser } = require('../ipc/network/authorize-user-in-system-browser');
const Oauth2Store = require('../store/oauth2');
const { makeAxiosInstance } = require('../ipc/network/axios-instance');
const { safeParseJSON, safeStringifyJSON } = require('./common');
const { preferencesUtil } = require('../store/preferences');
const qs = require('qs');
const BRUNO_OAUTH2_CALLBACK_URL = 'https://oauth2.usebruno.com/callback';
const oauth2Store = new Oauth2Store();
const persistOauth2Credentials = ({ collectionUid, url, credentials, credentialsId }) => {
@@ -147,6 +151,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo
autoFetchToken,
additionalParameters
} = oAuth;
const effectiveCallbackUrl = callbackUrl && callbackUrl.length ? callbackUrl : BRUNO_OAUTH2_CALLBACK_URL;
const url = requestCopy?.oauth2?.accessTokenUrl;
// Validate required fields
@@ -168,7 +173,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo
};
}
if (!callbackUrl) {
if (!effectiveCallbackUrl) {
return {
error: 'Callback URL is required for OAuth2 authorization code flow',
credentials: null,
@@ -251,10 +256,11 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo
if (credentialsPlacement === 'basic_auth_header') {
axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
}
const data = {
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: callbackUrl
redirect_uri: effectiveCallbackUrl
};
if (credentialsPlacement !== 'basic_auth_header') {
data.client_id = clientId;
@@ -295,13 +301,17 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => {
return new Promise(async (resolve, reject) => {
const { oauth2 } = request;
const { callbackUrl, clientId, authorizationUrl, scope, state, pkce, accessTokenUrl, additionalParameters } = oauth2;
const useSystemBrowser = preferencesUtil.shouldUseSystemBrowser();
const effectiveCallbackUrl = callbackUrl && callbackUrl.length ? callbackUrl : BRUNO_OAUTH2_CALLBACK_URL;
const authorizationUrlWithQueryParams = new URL(authorizationUrl);
authorizationUrlWithQueryParams.searchParams.append('response_type', 'code');
authorizationUrlWithQueryParams.searchParams.append('client_id', clientId);
if (callbackUrl) {
authorizationUrlWithQueryParams.searchParams.append('redirect_uri', callbackUrl);
if (effectiveCallbackUrl) {
authorizationUrlWithQueryParams.searchParams.append('redirect_uri', effectiveCallbackUrl);
}
if (scope) {
authorizationUrlWithQueryParams.searchParams.append('scope', scope);
}
@@ -324,9 +334,10 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => {
try {
const authorizeUrl = authorizationUrlWithQueryParams.toString();
const { authorizationCode, debugInfo } = await authorizeUserInWindow({
const authorizeFunction = useSystemBrowser ? authorizeUserInSystemBrowser : authorizeUserInWindow;
const { authorizationCode, debugInfo } = await authorizeFunction({
authorizeUrl,
callbackUrl,
callbackUrl: effectiveCallbackUrl,
session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: accessTokenUrl }),
additionalHeaders: getAdditionalHeaders(additionalParameters?.authorization)
});
@@ -656,7 +667,7 @@ const refreshOauth2Token = async ({ requestCopy, collectionUid, certsAndProxyCon
'Accept': 'application/json'
};
if (credentialsPlacement === 'basic_auth_header') {
axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret || ''}`).toString('base64')}`;
}
axiosRequestConfig.url = url;
axiosRequestConfig.responseType = 'arraybuffer';
@@ -739,6 +750,8 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF
autoFetchToken = true,
additionalParameters
} = oauth2;
const useSystemBrowser = preferencesUtil.shouldUseSystemBrowser();
const effectiveCallbackUrl = callbackUrl && callbackUrl.length ? callbackUrl : BRUNO_OAUTH2_CALLBACK_URL;
// Validate required fields
if (!authorizationUrl) {
@@ -750,7 +763,7 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF
};
}
if (!callbackUrl) {
if (!effectiveCallbackUrl) {
return {
error: 'Callback URL is required for OAuth2 implicit flow',
credentials: null,
@@ -815,7 +828,11 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF
const authorizationUrlWithQueryParams = new URL(authorizationUrl);
authorizationUrlWithQueryParams.searchParams.append('response_type', 'token');
authorizationUrlWithQueryParams.searchParams.append('client_id', clientId);
authorizationUrlWithQueryParams.searchParams.append('redirect_uri', callbackUrl);
if (effectiveCallbackUrl) {
authorizationUrlWithQueryParams.searchParams.append('redirect_uri', effectiveCallbackUrl);
}
if (scope) {
authorizationUrlWithQueryParams.searchParams.append('scope', scope);
}
@@ -835,14 +852,17 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF
const authorizeUrl = authorizationUrlWithQueryParams.toString();
try {
const { implicitTokens, debugInfo } = await authorizeUserInWindow({
const authorizeFunction = useSystemBrowser ? authorizeUserInSystemBrowser : authorizeUserInWindow;
const result = await authorizeFunction({
authorizeUrl,
callbackUrl,
callbackUrl: effectiveCallbackUrl,
session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: authorizationUrl }),
grantType: 'implicit',
additionalHeaders: getAdditionalHeaders(additionalParameters?.authorization)
});
const { implicitTokens, debugInfo } = result;
if (!implicitTokens || !implicitTokens.access_token) {
return {
error: 'No access token received from authorization server',