From 6a0532110900ff12baf7d4b1e85ffe9d360bbdff Mon Sep 17 00:00:00 2001 From: lohit Date: Mon, 11 Mar 2024 01:51:55 +0530 Subject: [PATCH] feat(#1003): closing stale 'authorize' windows | handling error, error_description, error_uri query params for oauth2 | clear authorize window cache for authorization_code oauth2 flow (#1719) * feat(#1003): oauth2 support --------- Co-authored-by: lohit-1 --- .../Auth/OAuth2/AuthorizationCode/index.js | 23 ++++- .../Auth/OAuth2/AuthorizationCode/index.js | 23 ++++- packages/bruno-app/src/utils/network/index.js | 7 ++ .../ipc/network/authorize-user-in-window.js | 33 ++++++- .../bruno-electron/src/ipc/network/index.js | 15 ++- .../src/ipc/network/oauth2-helper.js | 11 ++- packages/bruno-electron/src/store/oauth2.js | 99 +++++++++++++++++++ .../src/auth/oauth2/authorizationCode.js | 7 ++ 8 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 packages/bruno-electron/src/store/oauth2.js diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/index.js index 13b94a20a..674db53a8 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/index.js @@ -7,6 +7,8 @@ import { saveCollectionRoot, sendCollectionOauth2Request } from 'providers/Redux import StyledWrapper from './StyledWrapper'; import { inputsConfig } from './inputsConfig'; import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index'; +import { clearOauth2Cache } from 'utils/network/index'; +import toast from 'react-hot-toast'; const OAuth2AuthorizationCode = ({ collection }) => { const dispatch = useDispatch(); @@ -61,6 +63,16 @@ const OAuth2AuthorizationCode = ({ collection }) => { ); }; + const handleClearCache = (e) => { + clearOauth2Cache(collection?.uid) + .then(() => { + toast.success('cleared cache successfully'); + }) + .catch((err) => { + toast.error(err.message); + }); + }; + return ( {inputsConfig.map((input) => { @@ -90,9 +102,14 @@ const OAuth2AuthorizationCode = ({ collection }) => { onChange={handlePKCEToggle} /> - +
+ + +
); }; diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js index 47eb2cc6d..08a77555c 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js @@ -7,6 +7,8 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections'; import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; import { inputsConfig } from './inputsConfig'; +import { clearOauth2Cache } from 'utils/network/index'; +import toast from 'react-hot-toast'; const OAuth2AuthorizationCode = ({ item, collection }) => { const dispatch = useDispatch(); @@ -63,6 +65,16 @@ const OAuth2AuthorizationCode = ({ item, collection }) => { ); }; + const handleClearCache = (e) => { + clearOauth2Cache(collection?.uid) + .then(() => { + toast.success('cleared cache successfully'); + }) + .catch((err) => { + toast.error(err.message); + }); + }; + return ( {inputsConfig.map((input) => { @@ -92,9 +104,14 @@ const OAuth2AuthorizationCode = ({ item, collection }) => { onChange={handlePKCEToggle} /> - +
+ + +
); }; diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 2c2951592..e76a7debd 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -43,6 +43,13 @@ export const sendCollectionOauth2Request = async (collection, environment, colle }); }; +export const clearOauth2Cache = async (uid) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('clear-oauth2-cache', uid).then(resolve).catch(reject); + }); +}; + export const fetchGqlSchema = async (endpoint, environment, request, collection) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; diff --git a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js index 57cccd29c..d604d2df7 100644 --- a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js +++ b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js @@ -1,12 +1,22 @@ const { BrowserWindow } = require('electron'); -const authorizeUserInWindow = ({ authorizeUrl, callbackUrl }) => { +const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => { return new Promise(async (resolve, reject) => { let finalUrl = null; + let allOpenWindows = BrowserWindow.getAllWindows(); + + // main window id is '1' + // get all other windows + let windowsExcludingMain = allOpenWindows.filter((w) => w.id != 1); + windowsExcludingMain.forEach((w) => { + w.close(); + }); + const window = new BrowserWindow({ webPreferences: { - nodeIntegration: false + nodeIntegration: false, + partition: session }, show: false }); @@ -16,11 +26,24 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl }) => { // check if the url contains an authorization code if (url.match(/(code=).*/)) { finalUrl = url; - if (url && finalUrl.includes(callbackUrl)) { - window.close(); - } else { + if (!url || !finalUrl.includes(callbackUrl)) { reject(new Error('Invalid Callback Url')); } + window.close(); + } + if (url.match(/(error=).*/) || url.match(/(error_description=).*/) || url.match(/(error_uri=).*/)) { + const _url = new URL(url); + const error = _url.searchParams.get('error'); + const errorDescription = _url.searchParams.get('error_description'); + const errorUri = _url.searchParams.get('error_uri'); + let errorData = { + message: 'Authorization Failed!', + error, + errorDescription, + errorUri + }; + reject(new Error(JSON.stringify(errorData))); + window.close(); } } diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 3d100bd2f..c38dd3c89 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -35,6 +35,7 @@ const { transformClientCredentialsRequest, transformPasswordCredentialsRequest } = require('./oauth2-helper'); +const Oauth2Store = require('../../store/oauth2'); // override the default escape function to prevent escaping Mustache.escape = function (value) { @@ -201,7 +202,7 @@ const configureRequest = async ( case 'authorization_code': interpolateVars(requestCopy, envVars, collectionVariables, processEnvVars); const { data: authorizationCodeData, url: authorizationCodeAccessTokenUrl } = - await resolveOAuth2AuthorizationCodeAccessToken(requestCopy); + await resolveOAuth2AuthorizationCodeAccessToken(requestCopy, collectionUid); request.headers['content-type'] = 'application/x-www-form-urlencoded'; request.data = authorizationCodeData; request.url = authorizationCodeAccessTokenUrl; @@ -690,6 +691,18 @@ const registerNetworkIpc = (mainWindow) => { } }); + ipcMain.handle('clear-oauth2-cache', async (event, uid) => { + return new Promise((resolve, reject) => { + try { + const oauth2Store = new Oauth2Store(); + oauth2Store.clearSessionIdOfCollection(uid); + resolve(); + } catch (err) { + reject(new Error('Could not clear oauth2 cache')); + } + }); + }); + ipcMain.handle('cancel-http-request', async (event, cancelTokenUid) => { return new Promise((resolve, reject) => { if (cancelTokenUid && cancelTokens[cancelTokenUid]) { diff --git a/packages/bruno-electron/src/ipc/network/oauth2-helper.js b/packages/bruno-electron/src/ipc/network/oauth2-helper.js index 2134b7d1f..53297b81e 100644 --- a/packages/bruno-electron/src/ipc/network/oauth2-helper.js +++ b/packages/bruno-electron/src/ipc/network/oauth2-helper.js @@ -1,6 +1,7 @@ const { get, cloneDeep } = require('lodash'); const crypto = require('crypto'); const { authorizeUserInWindow } = require('./authorize-user-in-window'); +const Oauth2Store = require('../../store/oauth2'); const generateCodeVerifier = () => { return crypto.randomBytes(16).toString('hex'); @@ -15,12 +16,12 @@ const generateCodeChallenge = (codeVerifier) => { // AUTHORIZATION CODE -const resolveOAuth2AuthorizationCodeAccessToken = async (request) => { +const resolveOAuth2AuthorizationCodeAccessToken = async (request, collectionUid) => { let codeVerifier = generateCodeVerifier(); let codeChallenge = generateCodeChallenge(codeVerifier); let requestCopy = cloneDeep(request); - const { authorizationCode } = await getOAuth2AuthorizationCode(requestCopy, codeChallenge); + const { authorizationCode } = await getOAuth2AuthorizationCode(requestCopy, codeChallenge, collectionUid); const oAuth = get(requestCopy, 'oauth2', {}); const { clientId, clientSecret, callbackUrl, scope, pkce } = oAuth; const data = { @@ -42,7 +43,7 @@ const resolveOAuth2AuthorizationCodeAccessToken = async (request) => { }; }; -const getOAuth2AuthorizationCode = (request, codeChallenge) => { +const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => { return new Promise(async (resolve, reject) => { const { oauth2 } = request; const { callbackUrl, clientId, authorizationUrl, scope, pkce } = oauth2; @@ -55,9 +56,11 @@ const getOAuth2AuthorizationCode = (request, codeChallenge) => { } const authorizationUrlWithQueryParams = authorizationUrl + oauth2QueryParams; try { + const oauth2Store = new Oauth2Store(); const { authorizationCode } = await authorizeUserInWindow({ authorizeUrl: authorizationUrlWithQueryParams, - callbackUrl + callbackUrl, + session: oauth2Store.getSessionIdOfCollection(collectionUid) }); resolve({ authorizationCode }); } catch (err) { diff --git a/packages/bruno-electron/src/store/oauth2.js b/packages/bruno-electron/src/store/oauth2.js new file mode 100644 index 000000000..b0a2255b5 --- /dev/null +++ b/packages/bruno-electron/src/store/oauth2.js @@ -0,0 +1,99 @@ +const _ = require('lodash'); +const Store = require('electron-store'); +const { uuid } = require('../utils/common'); + +class Oauth2Store { + constructor() { + this.store = new Store({ + name: 'preferences', + clearInvalidConfig: true + }); + } + + // Get oauth2 data for all collections + getAllOauth2Data() { + let oauth2Data = this.store.get('oauth2'); + if (!Array.isArray(oauth2Data)) oauth2Data = []; + return oauth2Data; + } + + // Get oauth2 data for a collection + getOauth2DataOfCollection(collectionUid) { + let oauth2Data = this.getAllOauth2Data(); + let oauth2DataForCollection = oauth2Data.find((d) => d?.collectionUid == collectionUid); + + // If oauth2 data is not present for the collection, add it to the store + if (!oauth2DataForCollection) { + let newOauth2DataForCollection = { + collectionUid + }; + let updatedOauth2Data = [...oauth2Data, newOauth2DataForCollection]; + this.store.set('oauth2', updatedOauth2Data); + + return newOauth2DataForCollection; + } + + return oauth2DataForCollection; + } + + // Update oauth2 data of a collection + updateOauth2DataOfCollection(collectionUid, data) { + let oauth2Data = this.getAllOauth2Data(); + + let updatedOauth2Data = oauth2Data.filter((d) => d.collectionUid !== collectionUid); + updatedOauth2Data.push({ ...data }); + + this.store.set('oauth2', updatedOauth2Data); + } + + // Create a new oauth2 Session Id for a collection + createNewOauth2SessionIdForCollection(collectionUid) { + let oauth2DataForCollection = this.getOauth2DataOfCollection(collectionUid); + + let newSessionId = uuid(); + + let newOauth2DataForCollection = { + ...oauth2DataForCollection, + sessionId: newSessionId + }; + + this.updateOauth2DataOfCollection(collectionUid, newOauth2DataForCollection); + + return newOauth2DataForCollection; + } + + // Get session id of a collection + getSessionIdOfCollection(collectionUid) { + try { + let oauth2DataForCollection = this.getOauth2DataOfCollection(collectionUid); + + if (oauth2DataForCollection?.sessionId && typeof oauth2DataForCollection.sessionId === 'string') { + return oauth2DataForCollection.sessionId; + } + + let newOauth2DataForCollection = this.createNewOauth2SessionIdForCollection(collectionUid); + return newOauth2DataForCollection?.sessionId; + } catch (err) { + console.log('error retrieving session id from cache', err); + } + } + + // clear session id of a collection + clearSessionIdOfCollection(collectionUid) { + try { + let oauth2Data = this.getAllOauth2Data(); + + let oauth2DataForCollection = this.getOauth2DataOfCollection(collectionUid); + delete oauth2DataForCollection.sessionId; + + let updatedOauth2Data = oauth2Data.filter((d) => d.collectionUid !== collectionUid); + updatedOauth2Data.push({ ...oauth2DataForCollection }); + + this.store.set('oauth2', updatedOauth2Data); + } catch (err) { + console.log('error while clearing the oauth2 session cache', err); + } + } +} + +module.exports = Oauth2Store; diff --git a/packages/bruno-tests/src/auth/oauth2/authorizationCode.js b/packages/bruno-tests/src/auth/oauth2/authorizationCode.js index 1cc089a2c..0e45a8827 100644 --- a/packages/bruno-tests/src/auth/oauth2/authorizationCode.js +++ b/packages/bruno-tests/src/auth/oauth2/authorizationCode.js @@ -51,6 +51,13 @@ router.get('/authorize', (req, res) => { const redirectUrl = `${redirect_uri}?code=${authorization_code}`; + try { + // validating redirect URL + const url = new URL(redirectUrl); + } catch (err) { + return res.status(401).json({ error: 'Invalid redirect URI' }); + } + const _res = `