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 }) =>