mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 03:41:28 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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] || '';
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
const inputsConfig = [
|
||||
{
|
||||
key: 'callbackUrl',
|
||||
label: 'Callback URL'
|
||||
},
|
||||
{
|
||||
key: 'authorizationUrl',
|
||||
label: 'Authorization URL'
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
const inputsConfig = [
|
||||
{
|
||||
key: 'callbackUrl',
|
||||
label: 'Callback URL'
|
||||
},
|
||||
{
|
||||
key: 'authorizationUrl',
|
||||
label: 'Authorization URL'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ const initialState = {
|
||||
keepDefaultCaCertificates: {
|
||||
enabled: true
|
||||
},
|
||||
timeout: 0
|
||||
timeout: 0,
|
||||
oauth2: {
|
||||
useSystemBrowser: false
|
||||
}
|
||||
},
|
||||
font: {
|
||||
codeFont: 'default'
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
30
packages/bruno-electron/src/utils/deeplink.js
Normal file
30
packages/bruno-electron/src/utils/deeplink.js
Normal 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 };
|
||||
124
packages/bruno-electron/src/utils/oauth2-protocol-handler.js
Normal file
124
packages/bruno-electron/src/utils/oauth2-protocol-handler.js
Normal 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
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user