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

@@ -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',