From 1dad1621d79f4f28728004a9eccac3432b393b13 Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Tue, 20 Oct 2026 12:45:41 -0400 Subject: [PATCH 01/12] Ask Studio for the Entra MFA access token when the user-to-machine OAuth module is exposed. Studio builds that do not expose the module keep the authorization-code exchange. Co-authored-by: Cursor --- forward_engineering/api/testConnection.js | 3 +- forward_engineering/config.json | 1 + reverse_engineering/api.js | 7 +- reverse_engineering/clientManager.js | 5 +- reverse_engineering/config.json | 1 + .../databaseService/helpers/connection.js | 49 +--- reverse_engineering/helpers/mfaAccessToken.js | 113 ++++++++ .../helpers/mfaAccessToken.spec.js | 268 ++++++++++++++++++ 8 files changed, 399 insertions(+), 48 deletions(-) create mode 100644 reverse_engineering/helpers/mfaAccessToken.js create mode 100644 reverse_engineering/helpers/mfaAccessToken.spec.js diff --git a/forward_engineering/api/testConnection.js b/forward_engineering/api/testConnection.js index 93477ea6..f51669bc 100644 --- a/forward_engineering/api/testConnection.js +++ b/forward_engineering/api/testConnection.js @@ -1,5 +1,6 @@ const { getExternalBrowserUrl, connect } = require('../../reverse_engineering/api'); const logInfo = require('../../reverse_engineering/helpers/logInfo'); +const { isUserToMachineOauthAvailable } = require('../../reverse_engineering/helpers/mfaAccessToken'); const { logDatabaseVersion } = require('../../reverse_engineering/reverseEngineeringService/reverseEngineeringService'); async function getExternalBrowserUrlLocal(connectionInfo, logger, cb, app) { @@ -9,7 +10,7 @@ async function getExternalBrowserUrlLocal(connectionInfo, logger, cb, app) { async function testConnection(connectionInfo, logger, callback, app) { try { logInfo('Test connection', connectionInfo, logger); - if (connectionInfo.authMethod === 'Azure Active Directory (MFA)') { + if (connectionInfo.authMethod === 'Azure Active Directory (MFA)' && !isUserToMachineOauthAvailable({ app })) { await getExternalBrowserUrlLocal(connectionInfo, logger, callback, app); } else { const client = await connect(connectionInfo, logger, () => {}, app); diff --git a/forward_engineering/config.json b/forward_engineering/config.json index c07591d6..c72a8139 100644 --- a/forward_engineering/config.json +++ b/forward_engineering/config.json @@ -17,6 +17,7 @@ ] }, "externalBrowserPort": 8080, + "userToMachineOAuth": true, "level": { "container": true, "entity": true, diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index cadcd554..492df127 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -18,6 +18,7 @@ const getOptionsFromConnectionInfo = require('./helpers/getOptionsFromConnection const { adaptJsonSchema } = require('./helpers/adaptJsonSchema'); const { parseConnectionString } = require('./helpers/parseConnectionString'); const { prepareError } = require('./databaseService/helpers/errorService'); +const { isUserToMachineOauthAvailable } = require('./helpers/mfaAccessToken'); module.exports = { async connect(connectionInfo, logger, callback, app) { @@ -28,6 +29,7 @@ module.exports = { connectionInfo, logger, sshService: app.require('@hackolade/ssh-service'), + app, }); } @@ -42,7 +44,10 @@ module.exports = { async testConnection(connectionInfo, logger, callback, app) { try { logInfo('Test connection', connectionInfo, logger); - if (connectionInfo.authMethod === 'Azure Active Directory (MFA)') { + if ( + connectionInfo.authMethod === 'Azure Active Directory (MFA)' && + !isUserToMachineOauthAvailable({ app }) + ) { await this.getExternalBrowserUrl(connectionInfo, logger, callback, app); } else { const client = await this.connect(connectionInfo, logger, () => {}, app); diff --git a/reverse_engineering/clientManager.js b/reverse_engineering/clientManager.js index 02d4ce84..98fba71d 100644 --- a/reverse_engineering/clientManager.js +++ b/reverse_engineering/clientManager.js @@ -9,7 +9,7 @@ class ClientManager { return this.#client; } - async initClient({ connectionInfo, sshService, attempts = 0, logger }) { + async initClient({ connectionInfo, sshService, attempts = 0, logger, app }) { if (!this.#sshService) { this.#sshService = sshService; } @@ -38,7 +38,7 @@ class ClientManager { } try { - this.#client = await getConnectionClient({ connectionInfo: connectionParams, logger }); + this.#client = await getConnectionClient({ connectionInfo: connectionParams, logger, app }); return this.#client; } catch (error) { @@ -57,6 +57,7 @@ class ClientManager { sshService, attempts: attempts + 1, logger, + app, }); } diff --git a/reverse_engineering/config.json b/reverse_engineering/config.json index 3d34e746..3071430e 100644 --- a/reverse_engineering/config.json +++ b/reverse_engineering/config.json @@ -16,6 +16,7 @@ } ], "externalBrowserPort": 8080, + "userToMachineOAuth": true, "helpUrl": "https://hackolade.com/help/ConnecttoaSQLServerinstance.html", "options": [ { diff --git a/reverse_engineering/databaseService/helpers/connection.js b/reverse_engineering/databaseService/helpers/connection.js index 3c5e5a6b..94fbc613 100644 --- a/reverse_engineering/databaseService/helpers/connection.js +++ b/reverse_engineering/databaseService/helpers/connection.js @@ -1,10 +1,10 @@ const fs = require('fs'); const sql = require('mssql'); -const { hckFetch } = require('@hackolade/fetch'); +const { getMfaAccessToken } = require('../../helpers/mfaAccessToken'); const QUERY_REQUEST_TIMEOUT = 60000; -async function getConnectionClient({ connectionInfo, logger }) { +async function getConnectionClient({ connectionInfo, logger, app }) { const userName = getUserName({ connectionInfo }); const tenantId = connectionInfo.connectionTenantId || connectionInfo.tenantId || 'common'; const clientId = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; @@ -17,7 +17,7 @@ async function getConnectionClient({ connectionInfo, logger }) { case 'Username / Password (Windows)': return await connectWithWindowsAuth({ connectionInfo, userName, sslOptions, timeout }); case 'Azure Active Directory (MFA)': - return await connectWithAzureMFA({ connectionInfo, sslOptions, tenantId, clientId, logger }); + return await connectWithAzureMFA({ connectionInfo, sslOptions, logger, app }); case 'Azure Active Directory (Username / Password)': return await connectWithAzurePassword({ connectionInfo, @@ -76,9 +76,8 @@ async function connectWithWindowsAuth({ connectionInfo, userName, sslOptions, ti }); } -async function connectWithAzureMFA({ connectionInfo, sslOptions, tenantId, clientId, logger }) { - const redirectUri = 'http://localhost:8080'; - const token = await getMFAToken({ connectionInfo, tenantId, clientId, redirectUri, logger }); +async function connectWithAzureMFA({ connectionInfo, sslOptions, logger, app }) { + const token = await getMfaAccessToken({ app, connectionInfo, logger }); return await sql.connect({ ...getBaseConnectionConfig({ connectionInfo, timeout: QUERY_REQUEST_TIMEOUT }), @@ -151,44 +150,6 @@ function getSslConfig({ connectionInfo }) { return {}; } -async function getMFAToken({ connectionInfo, tenantId, redirectUri, clientId, logger, agent }) { - try { - const urlParams = new URLSearchParams(); - urlParams.append('code', connectionInfo?.externalBrowserQuery?.code || ''); - urlParams.append('client_id', clientId); - urlParams.append('redirect_uri', redirectUri); - urlParams.append('grant_type', 'authorization_code'); - urlParams.append('code_verifier', connectionInfo?.proofKey); - - const options = { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Origin': 'http://localhost', - }, - body: urlParams, - }; - - const response = await hckFetch(`https://login.microsoftonline.com/organizations/oauth2/v2.0/token`, options); - const parsedResponse = await parseResponse(response); - - return parsedResponse?.access_token || ''; - } catch (error) { - logger.log('error', { message: error.message, stack: error.stack, error }, 'MFA auth error'); - return ''; - } -} - -async function parseResponse(response) { - if (response.status !== 200) { - const errorMessage = await response.text(); - throw new Error(errorMessage); - } - - return response.json(); -} - function getUserName({ connectionInfo }) { const hostName = getHostName(connectionInfo.host); return isEmail(connectionInfo.userName) && hostName diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js new file mode 100644 index 00000000..fa60def7 --- /dev/null +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -0,0 +1,113 @@ +'use strict'; + +const { hckFetch } = require('@hackolade/fetch'); + +const OAUTH_MODULE = '@hackolade/oauth'; +const OAUTH_NOT_EXPOSED_MESSAGE = `The module ${OAUTH_MODULE} is not exposed by the application to the plugins!`; +const ENTRA_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; +const TOKEN_ENDPOINT = 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token'; +const REDIRECT_URI = 'http://localhost:8080'; +const REDIRECT_PORT = 8080; + +const getAuthorizeTenant = ({ connectionInfo = {} } = {}) => + connectionInfo.connectionTenantId || connectionInfo.tenantId || 'common'; + +const getAuthorizationQuery = ({ connectionInfo = {} } = {}) => { + const authorizationQuery = { + prompt: 'select_account', + response_mode: 'query', + resource: 'https://database.windows.net/', + }; + + if (!connectionInfo.loginHint) { + return authorizationQuery; + } + + return { + ...authorizationQuery, + login_hint: connectionInfo.loginHint, + }; +}; + +const parseResponse = async response => { + if (response.status !== 200) { + const errorMessage = await response.text(); + throw new Error(errorMessage); + } + + return response.json(); +}; + +const exchangeAuthorizationCode = async ({ connectionInfo, logger } = {}) => { + try { + const urlParams = new URLSearchParams(); + + urlParams.append('code', connectionInfo?.externalBrowserQuery?.code || ''); + urlParams.append('client_id', ENTRA_CLIENT_ID); + urlParams.append('redirect_uri', REDIRECT_URI); + urlParams.append('grant_type', 'authorization_code'); + urlParams.append('code_verifier', connectionInfo?.proofKey); + + const response = await hckFetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Origin': 'http://localhost', + }, + body: urlParams, + }); + const responseData = await parseResponse(response); + + return responseData?.access_token || ''; + } catch (error) { + logger.log('error', { message: error.message, stack: error.stack, error }, 'MFA auth error'); + + return ''; + } +}; + +const loadOauthModule = ({ app } = {}) => { + try { + return app.require(OAUTH_MODULE); + } catch (error) { + if (error?.message === OAUTH_NOT_EXPOSED_MESSAGE) { + return null; + } + + throw error; + } +}; + +const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { + const oauth = loadOauthModule({ app }); + + if (!oauth) { + return exchangeAuthorizationCode({ connectionInfo, logger }); + } + + const { accessToken } = await oauth.getUserToMachineAccessToken({ + authorizationEndpoint: `https://login.microsoftonline.com/${getAuthorizeTenant({ connectionInfo })}/oauth2/authorize`, + tokenEndpoint: TOKEN_ENDPOINT, + clientId: ENTRA_CLIENT_ID, + port: REDIRECT_PORT, + failOnPortConflict: true, + authorizationQuery: getAuthorizationQuery({ connectionInfo }), + headers: { + Origin: 'http://localhost', + }, + }); + + if (!accessToken) { + throw new Error('Entra token response did not include an access token.'); + } + + return accessToken; +}; + +const isUserToMachineOauthAvailable = ({ app } = {}) => Boolean(loadOauthModule({ app })); + +module.exports = { + getMfaAccessToken, + isUserToMachineOauthAvailable, +}; diff --git a/reverse_engineering/helpers/mfaAccessToken.spec.js b/reverse_engineering/helpers/mfaAccessToken.spec.js new file mode 100644 index 00000000..bfa82dd3 --- /dev/null +++ b/reverse_engineering/helpers/mfaAccessToken.spec.js @@ -0,0 +1,268 @@ +'use strict'; + +const { registerHooks } = require('node:module'); +const assert = require('node:assert/strict'); +const { describe, it } = require('node:test'); + +globalThis.entraMfaFetch = async () => { + throw new Error('hckFetch should not be called'); +}; + +registerHooks({ + load(url, context, nextLoad) { + if (!url.includes('@hackolade/fetch')) { + return nextLoad(url, context); + } + + return { + format: 'commonjs', + source: 'exports.hckFetch = (...args) => globalThis.entraMfaFetch(...args);', + shortCircuit: true, + }; + }, +}); + +const { getMfaAccessToken } = require('./mfaAccessToken'); + +const ENTRA_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; +const OAUTH_NOT_EXPOSED_MESSAGE = 'The module @hackolade/oauth is not exposed by the application to the plugins!'; + +describe('getMfaAccessToken', { concurrency: false }, () => { + it('asks Studio for a user-to-machine token with the Entra authorize request', async () => { + const calls = []; + const app = { + require(name) { + assert.equal(name, '@hackolade/oauth'); + + return { + async getUserToMachineAccessToken(options) { + calls.push(options); + + return { accessToken: 'sql-access-token' }; + }, + }; + }, + }; + + const token = await getMfaAccessToken({ + app, + connectionInfo: { + connectionTenantId: 'saved-directory', + tenantId: 'control-plane-tenant', + loginHint: 'user name@example.com', + }, + logger: { log() {} }, + }); + + assert.equal(token, 'sql-access-token'); + assert.equal('scope' in calls[0], false); + assert.deepEqual(calls, [ + { + authorizationEndpoint: 'https://login.microsoftonline.com/saved-directory/oauth2/authorize', + tokenEndpoint: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', + clientId: ENTRA_CLIENT_ID, + port: 8080, + failOnPortConflict: true, + authorizationQuery: { + prompt: 'select_account', + response_mode: 'query', + resource: 'https://database.windows.net/', + login_hint: 'user name@example.com', + }, + headers: { + Origin: 'http://localhost', + }, + }, + ]); + }); + + it('uses the control-plane tenant when the connection tenant is empty and omits a missing login hint', async () => { + const calls = []; + const app = { + require() { + return { + async getUserToMachineAccessToken(options) { + calls.push(options); + + return { accessToken: 'sql-access-token' }; + }, + }; + }, + }; + + await getMfaAccessToken({ + app, + connectionInfo: { + connectionTenantId: '', + tenantId: 'control-plane-tenant', + }, + logger: { log() {} }, + }); + + assert.equal( + calls[0].authorizationEndpoint, + 'https://login.microsoftonline.com/control-plane-tenant/oauth2/authorize', + ); + assert.deepEqual(calls[0].authorizationQuery, { + prompt: 'select_account', + response_mode: 'query', + resource: 'https://database.windows.net/', + }); + }); + + it('uses the common tenant when no tenant id is set', async () => { + const calls = []; + const app = { + require() { + return { + async getUserToMachineAccessToken(options) { + calls.push(options); + + return { accessToken: 'sql-access-token' }; + }, + }; + }, + }; + + await getMfaAccessToken({ + app, + connectionInfo: {}, + logger: { log() {} }, + }); + + assert.equal(calls[0].authorizationEndpoint, 'https://login.microsoftonline.com/common/oauth2/authorize'); + }); + + it('rejects when Studio does not return an access token', async () => { + const app = { + require() { + return { + async getUserToMachineAccessToken() { + return {}; + }, + }; + }, + }; + + await assert.rejects( + () => + getMfaAccessToken({ + app, + connectionInfo: { connectionTenantId: 'saved-directory' }, + logger: { log() {} }, + }), + { message: 'Entra token response did not include an access token.' }, + ); + }); + + it('rejects when loading the OAuth module fails for another reason', async () => { + const failure = new Error('oauth crashed'); + const app = { + require() { + throw failure; + }, + }; + + await assert.rejects( + () => + getMfaAccessToken({ + app, + connectionInfo: {}, + logger: { log() {} }, + }), + error => error === failure, + ); + }); + + it('rejects when the OAuth module fails to return a token', async () => { + const failure = new Error('Entra rejected the login'); + const app = { + require() { + return { + async getUserToMachineAccessToken() { + throw failure; + }, + }; + }, + }; + + await assert.rejects( + () => + getMfaAccessToken({ + app, + connectionInfo: {}, + logger: { log() {} }, + }), + error => error === failure, + ); + }); + + it('exchanges the authorization code and proof key when the OAuth module is not exposed', async () => { + const requests = []; + globalThis.entraMfaFetch = async (url, options) => { + requests.push({ url, body: String(options.body) }); + + return { + status: 200, + json: async () => ({ access_token: 'exchanged-token' }), + }; + }; + const app = { + require() { + throw new Error(OAUTH_NOT_EXPOSED_MESSAGE); + }, + }; + + const token = await getMfaAccessToken({ + app, + connectionInfo: { + externalBrowserQuery: { code: 'auth-code' }, + proofKey: 'proof-key', + }, + logger: { log() {} }, + }); + + assert.equal(token, 'exchanged-token'); + assert.deepEqual(requests, [ + { + url: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', + body: 'code=auth-code&client_id=0dc36597-bc44-49f8-a4a7-ae5401959b85&redirect_uri=http%3A%2F%2Flocalhost%3A8080&grant_type=authorization_code&code_verifier=proof-key', + }, + ]); + }); + + it('logs a code exchange failure and returns an empty token', async () => { + const failure = new Error('network down'); + const entries = []; + globalThis.entraMfaFetch = async () => { + throw failure; + }; + const app = { + require() { + throw new Error(OAUTH_NOT_EXPOSED_MESSAGE); + }, + }; + + const token = await getMfaAccessToken({ + app, + connectionInfo: { + externalBrowserQuery: { code: 'auth-code' }, + proofKey: 'proof-key', + }, + logger: { + log(level, data, message) { + entries.push({ level, data, message }); + }, + }, + }); + + assert.equal(token, ''); + assert.deepEqual(entries, [ + { + level: 'error', + data: { message: 'network down', stack: failure.stack, error: failure }, + message: 'MFA auth error', + }, + ]); + }); +}); From 5151067c5e9e7013c97d9817a7957e7d8ba21b4d Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Tue, 20 Oct 2026 13:07:21 -0400 Subject: [PATCH 02/12] Share the MFA external-browser decision in the token helper so both test-connection paths open SQL when the module is present and keep the code exchange when it is not. Co-authored-by: Cursor --- forward_engineering/api/testConnection.js | 4 +- reverse_engineering/api.js | 7 +--- reverse_engineering/helpers/mfaAccessToken.js | 10 ++++- .../helpers/mfaAccessToken.spec.js | 40 ++++++++++++++++++- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/forward_engineering/api/testConnection.js b/forward_engineering/api/testConnection.js index f51669bc..5eb3546f 100644 --- a/forward_engineering/api/testConnection.js +++ b/forward_engineering/api/testConnection.js @@ -1,6 +1,6 @@ const { getExternalBrowserUrl, connect } = require('../../reverse_engineering/api'); const logInfo = require('../../reverse_engineering/helpers/logInfo'); -const { isUserToMachineOauthAvailable } = require('../../reverse_engineering/helpers/mfaAccessToken'); +const { shouldUseExternalBrowser } = require('../../reverse_engineering/helpers/mfaAccessToken'); const { logDatabaseVersion } = require('../../reverse_engineering/reverseEngineeringService/reverseEngineeringService'); async function getExternalBrowserUrlLocal(connectionInfo, logger, cb, app) { @@ -10,7 +10,7 @@ async function getExternalBrowserUrlLocal(connectionInfo, logger, cb, app) { async function testConnection(connectionInfo, logger, callback, app) { try { logInfo('Test connection', connectionInfo, logger); - if (connectionInfo.authMethod === 'Azure Active Directory (MFA)' && !isUserToMachineOauthAvailable({ app })) { + if (shouldUseExternalBrowser({ app, connectionInfo })) { await getExternalBrowserUrlLocal(connectionInfo, logger, callback, app); } else { const client = await connect(connectionInfo, logger, () => {}, app); diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index 492df127..de5fc7dd 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -18,7 +18,7 @@ const getOptionsFromConnectionInfo = require('./helpers/getOptionsFromConnection const { adaptJsonSchema } = require('./helpers/adaptJsonSchema'); const { parseConnectionString } = require('./helpers/parseConnectionString'); const { prepareError } = require('./databaseService/helpers/errorService'); -const { isUserToMachineOauthAvailable } = require('./helpers/mfaAccessToken'); +const { shouldUseExternalBrowser } = require('./helpers/mfaAccessToken'); module.exports = { async connect(connectionInfo, logger, callback, app) { @@ -44,10 +44,7 @@ module.exports = { async testConnection(connectionInfo, logger, callback, app) { try { logInfo('Test connection', connectionInfo, logger); - if ( - connectionInfo.authMethod === 'Azure Active Directory (MFA)' && - !isUserToMachineOauthAvailable({ app }) - ) { + if (shouldUseExternalBrowser({ app, connectionInfo })) { await this.getExternalBrowserUrl(connectionInfo, logger, callback, app); } else { const client = await this.connect(connectionInfo, logger, () => {}, app); diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js index fa60def7..e4304cdf 100644 --- a/reverse_engineering/helpers/mfaAccessToken.js +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -105,9 +105,15 @@ const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { return accessToken; }; -const isUserToMachineOauthAvailable = ({ app } = {}) => Boolean(loadOauthModule({ app })); +const shouldUseExternalBrowser = ({ app, connectionInfo } = {}) => { + if (connectionInfo.authMethod !== 'Azure Active Directory (MFA)') { + return false; + } + + return !loadOauthModule({ app }); +}; module.exports = { getMfaAccessToken, - isUserToMachineOauthAvailable, + shouldUseExternalBrowser, }; diff --git a/reverse_engineering/helpers/mfaAccessToken.spec.js b/reverse_engineering/helpers/mfaAccessToken.spec.js index bfa82dd3..afdbdf71 100644 --- a/reverse_engineering/helpers/mfaAccessToken.spec.js +++ b/reverse_engineering/helpers/mfaAccessToken.spec.js @@ -22,7 +22,7 @@ registerHooks({ }, }); -const { getMfaAccessToken } = require('./mfaAccessToken'); +const { getMfaAccessToken, shouldUseExternalBrowser } = require('./mfaAccessToken'); const ENTRA_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; const OAUTH_NOT_EXPOSED_MESSAGE = 'The module @hackolade/oauth is not exposed by the application to the plugins!'; @@ -266,3 +266,41 @@ describe('getMfaAccessToken', { concurrency: false }, () => { ]); }); }); + +describe('shouldUseExternalBrowser', { concurrency: false }, () => { + const mfaConnectionInfo = { authMethod: 'Azure Active Directory (MFA)' }; + + it('opens SQL when the OAuth module is present', () => { + const app = { + require() { + return {}; + }, + }; + + assert.equal(shouldUseExternalBrowser({ app, connectionInfo: mfaConnectionInfo }), false); + }); + + it('keeps the external browser when the OAuth module is not exposed', () => { + const app = { + require() { + throw new Error(OAUTH_NOT_EXPOSED_MESSAGE); + }, + }; + + assert.equal(shouldUseExternalBrowser({ app, connectionInfo: mfaConnectionInfo }), true); + }); + + it('surfaces any other failure while loading the OAuth module', () => { + const failure = new Error('oauth crashed'); + const app = { + require() { + throw failure; + }, + }; + + assert.throws( + () => shouldUseExternalBrowser({ app, connectionInfo: mfaConnectionInfo }), + error => error === failure, + ); + }); +}); From 3b4ad476584afc4676609a44b2b21028653d4862 Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Tue, 20 Oct 2026 14:05:09 -0400 Subject: [PATCH 03/12] adapt the strategy --- forward_engineering/config.json | 1 - reverse_engineering/api.js | 7 +++- reverse_engineering/config.json | 1 - reverse_engineering/helpers/mfaAccessToken.js | 12 ++++-- .../helpers/mfaAccessToken.spec.js | 42 ++++++++++++++++++- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/forward_engineering/config.json b/forward_engineering/config.json index c72a8139..c07591d6 100644 --- a/forward_engineering/config.json +++ b/forward_engineering/config.json @@ -17,7 +17,6 @@ ] }, "externalBrowserPort": 8080, - "userToMachineOAuth": true, "level": { "container": true, "entity": true, diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index de5fc7dd..3ac28519 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -18,7 +18,7 @@ const getOptionsFromConnectionInfo = require('./helpers/getOptionsFromConnection const { adaptJsonSchema } = require('./helpers/adaptJsonSchema'); const { parseConnectionString } = require('./helpers/parseConnectionString'); const { prepareError } = require('./databaseService/helpers/errorService'); -const { shouldUseExternalBrowser } = require('./helpers/mfaAccessToken'); +const { canRequestUserToMachineAccessToken, shouldUseExternalBrowser } = require('./helpers/mfaAccessToken'); module.exports = { async connect(connectionInfo, logger, callback, app) { @@ -67,6 +67,11 @@ module.exports = { }, async getExternalBrowserUrl(connectionInfo, logger, cb, app) { + if (canRequestUserToMachineAccessToken({ app, connectionInfo })) { + cb(null, {}); + return; + } + const verifier = randomstring.generate(32); const base64Digest = crypto.createHash('sha256').update(verifier).digest('base64'); const challenge = base64url.fromBase64(base64Digest); diff --git a/reverse_engineering/config.json b/reverse_engineering/config.json index 3071430e..3d34e746 100644 --- a/reverse_engineering/config.json +++ b/reverse_engineering/config.json @@ -16,7 +16,6 @@ } ], "externalBrowserPort": 8080, - "userToMachineOAuth": true, "helpUrl": "https://hackolade.com/help/ConnecttoaSQLServerinstance.html", "options": [ { diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js index e4304cdf..3e04e8e7 100644 --- a/reverse_engineering/helpers/mfaAccessToken.js +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -105,15 +105,21 @@ const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { return accessToken; }; -const shouldUseExternalBrowser = ({ app, connectionInfo } = {}) => { - if (connectionInfo.authMethod !== 'Azure Active Directory (MFA)') { +const isEntraMfa = ({ connectionInfo } = {}) => connectionInfo?.authMethod === 'Azure Active Directory (MFA)'; + +const canRequestUserToMachineAccessToken = ({ app, connectionInfo } = {}) => { + if (!isEntraMfa({ connectionInfo })) { return false; } - return !loadOauthModule({ app }); + return Boolean(loadOauthModule({ app })); }; +const shouldUseExternalBrowser = ({ app, connectionInfo } = {}) => + isEntraMfa({ connectionInfo }) && !canRequestUserToMachineAccessToken({ app, connectionInfo }); + module.exports = { getMfaAccessToken, + canRequestUserToMachineAccessToken, shouldUseExternalBrowser, }; diff --git a/reverse_engineering/helpers/mfaAccessToken.spec.js b/reverse_engineering/helpers/mfaAccessToken.spec.js index afdbdf71..2141cbdd 100644 --- a/reverse_engineering/helpers/mfaAccessToken.spec.js +++ b/reverse_engineering/helpers/mfaAccessToken.spec.js @@ -9,8 +9,18 @@ globalThis.entraMfaFetch = async () => { }; registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier !== '@hackolade/fetch') { + return nextResolve(specifier, context); + } + + return { + url: 'file:///hackolade-fetch-stub', + shortCircuit: true, + }; + }, load(url, context, nextLoad) { - if (!url.includes('@hackolade/fetch')) { + if (url !== 'file:///hackolade-fetch-stub') { return nextLoad(url, context); } @@ -22,7 +32,11 @@ registerHooks({ }, }); -const { getMfaAccessToken, shouldUseExternalBrowser } = require('./mfaAccessToken'); +const { + getMfaAccessToken, + canRequestUserToMachineAccessToken, + shouldUseExternalBrowser, +} = require('./mfaAccessToken'); const ENTRA_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; const OAUTH_NOT_EXPOSED_MESSAGE = 'The module @hackolade/oauth is not exposed by the application to the plugins!'; @@ -267,6 +281,30 @@ describe('getMfaAccessToken', { concurrency: false }, () => { }); }); +describe('canRequestUserToMachineAccessToken', { concurrency: false }, () => { + const mfaConnectionInfo = { authMethod: 'Azure Active Directory (MFA)' }; + + it('uses the user-to-machine call when the OAuth module is present', () => { + const app = { + require() { + return {}; + }, + }; + + assert.equal(canRequestUserToMachineAccessToken({ app, connectionInfo: mfaConnectionInfo }), true); + }); + + it('keeps the external browser when the OAuth module is not exposed', () => { + const app = { + require() { + throw new Error(OAUTH_NOT_EXPOSED_MESSAGE); + }, + }; + + assert.equal(canRequestUserToMachineAccessToken({ app, connectionInfo: mfaConnectionInfo }), false); + }); +}); + describe('shouldUseExternalBrowser', { concurrency: false }, () => { const mfaConnectionInfo = { authMethod: 'Azure Active Directory (MFA)' }; From e2f1d7c51d97b6c741c81e6c8ef6c01601385191 Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Thu, 22 Oct 2026 10:29:54 -0400 Subject: [PATCH 04/12] Do not hardcode azure entra id parameters --- reverse_engineering/api.js | 7 +-- .../databaseService/helpers/connection.js | 6 +-- .../helpers/entraOAuthConfig.js | 46 +++++++++++++++++++ reverse_engineering/helpers/mfaAccessToken.js | 39 ++++++---------- .../helpers/mfaAccessToken.spec.js | 15 ++++++ 5 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 reverse_engineering/helpers/entraOAuthConfig.js diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index 3ac28519..e3357f92 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -19,6 +19,7 @@ const { adaptJsonSchema } = require('./helpers/adaptJsonSchema'); const { parseConnectionString } = require('./helpers/parseConnectionString'); const { prepareError } = require('./databaseService/helpers/errorService'); const { canRequestUserToMachineAccessToken, shouldUseExternalBrowser } = require('./helpers/mfaAccessToken'); +const { getEntraOAuthConfig, resolveAuthorizeTenant } = require('./helpers/entraOAuthConfig'); module.exports = { async connect(connectionInfo, logger, callback, app) { @@ -75,14 +76,14 @@ module.exports = { const verifier = randomstring.generate(32); const base64Digest = crypto.createHash('sha256').update(verifier).digest('base64'); const challenge = base64url.fromBase64(base64Digest); - const tenantId = connectionInfo.connectionTenantId || connectionInfo.tenantId || 'common'; - const clientId = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; + const tenantId = resolveAuthorizeTenant({ connectionInfo }); + const { productionClientId, authorizationEndpoint } = getEntraOAuthConfig({ app, tenantId }); const loginHint = connectionInfo.loginHint ? `login_hint=${encodeURIComponent(connectionInfo.loginHint)}&` : ''; const redirectUrl = `http://localhost:${connectionInfo.redirectPort || 8080}`; cb(null, { proofKey: verifier, - url: `https://login.microsoftonline.com/${tenantId}/oauth2/authorize?${loginHint}code_challenge_method=S256&code_challenge=${challenge}&response_type=code&response_mode=query&client_id=${clientId}&redirect_uri=${redirectUrl}&prompt=select_account&resource=https://database.windows.net/`, + url: `${authorizationEndpoint}?${loginHint}code_challenge_method=S256&code_challenge=${challenge}&response_type=code&response_mode=query&client_id=${productionClientId}&redirect_uri=${redirectUrl}&prompt=select_account&resource=https://database.windows.net/`, }); }, diff --git a/reverse_engineering/databaseService/helpers/connection.js b/reverse_engineering/databaseService/helpers/connection.js index 94fbc613..e1196fc0 100644 --- a/reverse_engineering/databaseService/helpers/connection.js +++ b/reverse_engineering/databaseService/helpers/connection.js @@ -1,13 +1,13 @@ const fs = require('fs'); const sql = require('mssql'); const { getMfaAccessToken } = require('../../helpers/mfaAccessToken'); +const { getEntraOAuthConfig, resolveAuthorizeTenant } = require('../../helpers/entraOAuthConfig'); const QUERY_REQUEST_TIMEOUT = 60000; async function getConnectionClient({ connectionInfo, logger, app }) { const userName = getUserName({ connectionInfo }); - const tenantId = connectionInfo.connectionTenantId || connectionInfo.tenantId || 'common'; - const clientId = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; + const tenantId = resolveAuthorizeTenant({ connectionInfo }); const sslOptions = getSslConfig({ connectionInfo }); const timeout = Number(connectionInfo.queryRequestTimeout) || QUERY_REQUEST_TIMEOUT; @@ -25,7 +25,7 @@ async function getConnectionClient({ connectionInfo, logger, app }) { sslOptions, timeout, tenantId, - clientId, + clientId: getEntraOAuthConfig({ app }).productionClientId, }); default: return await sql.connect(connectionInfo.connectionString); diff --git a/reverse_engineering/helpers/entraOAuthConfig.js b/reverse_engineering/helpers/entraOAuthConfig.js new file mode 100644 index 00000000..ad357c56 --- /dev/null +++ b/reverse_engineering/helpers/entraOAuthConfig.js @@ -0,0 +1,46 @@ +'use strict'; + +const OAUTH_MODULE = '@hackolade/oauth'; +const OAUTH_NOT_EXPOSED_MESSAGE = `The module ${OAUTH_MODULE} is not exposed by the application to the plugins!`; +const FALLBACK_PRODUCTION_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; +const FALLBACK_TOKEN_ENDPOINT = 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token'; +const FALLBACK_AUTHORIZE_ENDPOINT = 'https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize'; + +const loadOauthModule = ({ app } = {}) => { + try { + return app.require(OAUTH_MODULE); + } catch (error) { + if (error?.message === OAUTH_NOT_EXPOSED_MESSAGE) { + return null; + } + + throw error; + } +}; + +const resolveAuthorizeTenant = ({ connectionInfo = {} } = {}) => + connectionInfo.connectionTenantId || connectionInfo.tenantId || 'common'; + +const getLegacyEntraOAuthConfig = ({ tenantId } = {}) => ({ + productionClientId: FALLBACK_PRODUCTION_CLIENT_ID, + authorizationEndpoint: tenantId + ? `https://login.microsoftonline.com/${tenantId}/oauth2/authorize` + : FALLBACK_AUTHORIZE_ENDPOINT, + tokenEndpoint: FALLBACK_TOKEN_ENDPOINT, +}); + +const getEntraOAuthConfig = ({ app, tenantId } = {}) => { + const oauth = loadOauthModule({ app }); + + if (!oauth) { + return getLegacyEntraOAuthConfig({ tenantId }); + } + + return oauth.getEntraIdOAuthConfig(tenantId === undefined ? {} : { tenantId }); +}; + +module.exports = { + loadOauthModule, + resolveAuthorizeTenant, + getEntraOAuthConfig, +}; diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js index 3e04e8e7..b4044a6b 100644 --- a/reverse_engineering/helpers/mfaAccessToken.js +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -2,16 +2,11 @@ const { hckFetch } = require('@hackolade/fetch'); -const OAUTH_MODULE = '@hackolade/oauth'; -const OAUTH_NOT_EXPOSED_MESSAGE = `The module ${OAUTH_MODULE} is not exposed by the application to the plugins!`; -const ENTRA_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; -const TOKEN_ENDPOINT = 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token'; +const { getEntraOAuthConfig, loadOauthModule, resolveAuthorizeTenant } = require('./entraOAuthConfig'); + const REDIRECT_URI = 'http://localhost:8080'; const REDIRECT_PORT = 8080; -const getAuthorizeTenant = ({ connectionInfo = {} } = {}) => - connectionInfo.connectionTenantId || connectionInfo.tenantId || 'common'; - const getAuthorizationQuery = ({ connectionInfo = {} } = {}) => { const authorizationQuery = { prompt: 'select_account', @@ -38,17 +33,18 @@ const parseResponse = async response => { return response.json(); }; -const exchangeAuthorizationCode = async ({ connectionInfo, logger } = {}) => { +const exchangeAuthorizationCode = async ({ app, connectionInfo, logger } = {}) => { try { + const { productionClientId, tokenEndpoint } = getEntraOAuthConfig({ app }); const urlParams = new URLSearchParams(); urlParams.append('code', connectionInfo?.externalBrowserQuery?.code || ''); - urlParams.append('client_id', ENTRA_CLIENT_ID); + urlParams.append('client_id', productionClientId); urlParams.append('redirect_uri', REDIRECT_URI); urlParams.append('grant_type', 'authorization_code'); urlParams.append('code_verifier', connectionInfo?.proofKey); - const response = await hckFetch(TOKEN_ENDPOINT, { + const response = await hckFetch(tokenEndpoint, { method: 'POST', headers: { 'Accept': 'application/json', @@ -67,29 +63,20 @@ const exchangeAuthorizationCode = async ({ connectionInfo, logger } = {}) => { } }; -const loadOauthModule = ({ app } = {}) => { - try { - return app.require(OAUTH_MODULE); - } catch (error) { - if (error?.message === OAUTH_NOT_EXPOSED_MESSAGE) { - return null; - } - - throw error; - } -}; - const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { const oauth = loadOauthModule({ app }); if (!oauth) { - return exchangeAuthorizationCode({ connectionInfo, logger }); + return exchangeAuthorizationCode({ app, connectionInfo, logger }); } + const { productionClientId, authorizationEndpoint, tokenEndpoint } = oauth.getEntraIdOAuthConfig({ + tenantId: resolveAuthorizeTenant({ connectionInfo }), + }); const { accessToken } = await oauth.getUserToMachineAccessToken({ - authorizationEndpoint: `https://login.microsoftonline.com/${getAuthorizeTenant({ connectionInfo })}/oauth2/authorize`, - tokenEndpoint: TOKEN_ENDPOINT, - clientId: ENTRA_CLIENT_ID, + authorizationEndpoint, + tokenEndpoint, + clientId: productionClientId, port: REDIRECT_PORT, failOnPortConflict: true, authorizationQuery: getAuthorizationQuery({ connectionInfo }), diff --git a/reverse_engineering/helpers/mfaAccessToken.spec.js b/reverse_engineering/helpers/mfaAccessToken.spec.js index 2141cbdd..b7744aca 100644 --- a/reverse_engineering/helpers/mfaAccessToken.spec.js +++ b/reverse_engineering/helpers/mfaAccessToken.spec.js @@ -41,6 +41,16 @@ const { const ENTRA_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; const OAUTH_NOT_EXPOSED_MESSAGE = 'The module @hackolade/oauth is not exposed by the application to the plugins!'; +const getEntraIdOAuthConfig = ({ tenantId } = {}) => ({ + productionClientId: ENTRA_CLIENT_ID, + testingClientId: 'f28311c1-422d-4c77-8bbb-ff2fa1b84b7a', + authorizationEndpoint: tenantId + ? `https://login.microsoftonline.com/${tenantId}/oauth2/authorize` + : 'https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize', + tokenEndpoint: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', + adminConsentBase: 'https://login.microsoftonline.com/organizations/adminconsent', +}); + describe('getMfaAccessToken', { concurrency: false }, () => { it('asks Studio for a user-to-machine token with the Entra authorize request', async () => { const calls = []; @@ -49,6 +59,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { assert.equal(name, '@hackolade/oauth'); return { + getEntraIdOAuthConfig, async getUserToMachineAccessToken(options) { calls.push(options); @@ -95,6 +106,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { const app = { require() { return { + getEntraIdOAuthConfig, async getUserToMachineAccessToken(options) { calls.push(options); @@ -129,6 +141,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { const app = { require() { return { + getEntraIdOAuthConfig, async getUserToMachineAccessToken(options) { calls.push(options); @@ -151,6 +164,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { const app = { require() { return { + getEntraIdOAuthConfig, async getUserToMachineAccessToken() { return {}; }, @@ -193,6 +207,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { const app = { require() { return { + getEntraIdOAuthConfig, async getUserToMachineAccessToken() { throw failure; }, From 26f926c4a44c794326b3176831bd2089a812d1a2 Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Thu, 22 Oct 2026 10:30:15 -0400 Subject: [PATCH 05/12] 0.2.55 --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index ba61e128..7cb1dd9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "SQLServer", - "version": "0.2.54", + "version": "0.2.55", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "SQLServer", - "version": "0.2.54", + "version": "0.2.55", "dependencies": { "@hackolade/fetch": "1.3.0", "base64url": "3.0.1", diff --git a/package.json b/package.json index 7d05eb62..98d3c438 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "SQLServer", - "version": "0.2.54", + "version": "0.2.55", "author": "hackolade", "engines": { "hackolade": "7.7.10", @@ -103,4 +103,4 @@ "uuid": "14.0.0", "brace-expansion": "5.0.12" } -} \ No newline at end of file +} From 1d2ec5cb2e084d310e45122a0b91a325f89437e9 Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Thu, 22 Oct 2026 11:31:27 -0400 Subject: [PATCH 06/12] Rename login function --- reverse_engineering/helpers/mfaAccessToken.js | 8 +++++--- .../helpers/mfaAccessToken.spec.js | 16 +++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js index b4044a6b..3e39a17a 100644 --- a/reverse_engineering/helpers/mfaAccessToken.js +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -73,15 +73,17 @@ const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { const { productionClientId, authorizationEndpoint, tokenEndpoint } = oauth.getEntraIdOAuthConfig({ tenantId: resolveAuthorizeTenant({ connectionInfo }), }); - const { accessToken } = await oauth.getUserToMachineAccessToken({ + const { accessToken } = await oauth.browserLoginFlow({ authorizationEndpoint, tokenEndpoint, clientId: productionClientId, port: REDIRECT_PORT, failOnPortConflict: true, authorizationQuery: getAuthorizationQuery({ connectionInfo }), - headers: { - Origin: 'http://localhost', + tokenRequestOptions: { + headers: { + Origin: 'http://localhost', + }, }, }); diff --git a/reverse_engineering/helpers/mfaAccessToken.spec.js b/reverse_engineering/helpers/mfaAccessToken.spec.js index b7744aca..8c7a7059 100644 --- a/reverse_engineering/helpers/mfaAccessToken.spec.js +++ b/reverse_engineering/helpers/mfaAccessToken.spec.js @@ -60,7 +60,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { return { getEntraIdOAuthConfig, - async getUserToMachineAccessToken(options) { + async browserLoginFlow(options) { calls.push(options); return { accessToken: 'sql-access-token' }; @@ -94,8 +94,10 @@ describe('getMfaAccessToken', { concurrency: false }, () => { resource: 'https://database.windows.net/', login_hint: 'user name@example.com', }, - headers: { - Origin: 'http://localhost', + tokenRequestOptions: { + headers: { + Origin: 'http://localhost', + }, }, }, ]); @@ -107,7 +109,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { require() { return { getEntraIdOAuthConfig, - async getUserToMachineAccessToken(options) { + async browserLoginFlow(options) { calls.push(options); return { accessToken: 'sql-access-token' }; @@ -142,7 +144,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { require() { return { getEntraIdOAuthConfig, - async getUserToMachineAccessToken(options) { + async browserLoginFlow(options) { calls.push(options); return { accessToken: 'sql-access-token' }; @@ -165,7 +167,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { require() { return { getEntraIdOAuthConfig, - async getUserToMachineAccessToken() { + async browserLoginFlow() { return {}; }, }; @@ -208,7 +210,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { require() { return { getEntraIdOAuthConfig, - async getUserToMachineAccessToken() { + async browserLoginFlow() { throw failure; }, }; From 2ddbe87462656a415b5a091f72b6325cc3205e55 Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Thu, 22 Oct 2026 11:42:47 -0400 Subject: [PATCH 07/12] no need to add origin header anymore --- reverse_engineering/helpers/entraOAuthConfig.js | 2 ++ reverse_engineering/helpers/mfaAccessToken.js | 9 ++------- reverse_engineering/helpers/mfaAccessToken.spec.js | 6 +----- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/reverse_engineering/helpers/entraOAuthConfig.js b/reverse_engineering/helpers/entraOAuthConfig.js index ad357c56..bfc29ffc 100644 --- a/reverse_engineering/helpers/entraOAuthConfig.js +++ b/reverse_engineering/helpers/entraOAuthConfig.js @@ -5,6 +5,7 @@ const OAUTH_NOT_EXPOSED_MESSAGE = `The module ${OAUTH_MODULE} is not exposed by const FALLBACK_PRODUCTION_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; const FALLBACK_TOKEN_ENDPOINT = 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token'; const FALLBACK_AUTHORIZE_ENDPOINT = 'https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize'; +const FALLBACK_DESKTOP_TOKEN_REQUEST_HEADERS = { Origin: 'http://localhost' }; const loadOauthModule = ({ app } = {}) => { try { @@ -27,6 +28,7 @@ const getLegacyEntraOAuthConfig = ({ tenantId } = {}) => ({ ? `https://login.microsoftonline.com/${tenantId}/oauth2/authorize` : FALLBACK_AUTHORIZE_ENDPOINT, tokenEndpoint: FALLBACK_TOKEN_ENDPOINT, + desktopTokenRequestHeaders: FALLBACK_DESKTOP_TOKEN_REQUEST_HEADERS, }); const getEntraOAuthConfig = ({ app, tenantId } = {}) => { diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js index 3e39a17a..320c26c8 100644 --- a/reverse_engineering/helpers/mfaAccessToken.js +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -35,7 +35,7 @@ const parseResponse = async response => { const exchangeAuthorizationCode = async ({ app, connectionInfo, logger } = {}) => { try { - const { productionClientId, tokenEndpoint } = getEntraOAuthConfig({ app }); + const { productionClientId, tokenEndpoint, desktopTokenRequestHeaders } = getEntraOAuthConfig({ app }); const urlParams = new URLSearchParams(); urlParams.append('code', connectionInfo?.externalBrowserQuery?.code || ''); @@ -49,7 +49,7 @@ const exchangeAuthorizationCode = async ({ app, connectionInfo, logger } = {}) = headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', - 'Origin': 'http://localhost', + ...desktopTokenRequestHeaders, }, body: urlParams, }); @@ -80,11 +80,6 @@ const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { port: REDIRECT_PORT, failOnPortConflict: true, authorizationQuery: getAuthorizationQuery({ connectionInfo }), - tokenRequestOptions: { - headers: { - Origin: 'http://localhost', - }, - }, }); if (!accessToken) { diff --git a/reverse_engineering/helpers/mfaAccessToken.spec.js b/reverse_engineering/helpers/mfaAccessToken.spec.js index 8c7a7059..e26391ee 100644 --- a/reverse_engineering/helpers/mfaAccessToken.spec.js +++ b/reverse_engineering/helpers/mfaAccessToken.spec.js @@ -49,6 +49,7 @@ const getEntraIdOAuthConfig = ({ tenantId } = {}) => ({ : 'https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize', tokenEndpoint: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', adminConsentBase: 'https://login.microsoftonline.com/organizations/adminconsent', + desktopTokenRequestHeaders: { Origin: 'http://localhost' }, }); describe('getMfaAccessToken', { concurrency: false }, () => { @@ -94,11 +95,6 @@ describe('getMfaAccessToken', { concurrency: false }, () => { resource: 'https://database.windows.net/', login_hint: 'user name@example.com', }, - tokenRequestOptions: { - headers: { - Origin: 'http://localhost', - }, - }, }, ]); }); From 3857a38b1c0e35b132294925872426501105199c Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Thu, 22 Oct 2026 12:04:12 -0400 Subject: [PATCH 08/12] await was missing --- reverse_engineering/api.js | 2 +- reverse_engineering/databaseService/helpers/connection.js | 2 +- reverse_engineering/helpers/entraOAuthConfig.js | 2 +- reverse_engineering/helpers/mfaAccessToken.js | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index e3357f92..4b8f38fb 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -77,7 +77,7 @@ module.exports = { const base64Digest = crypto.createHash('sha256').update(verifier).digest('base64'); const challenge = base64url.fromBase64(base64Digest); const tenantId = resolveAuthorizeTenant({ connectionInfo }); - const { productionClientId, authorizationEndpoint } = getEntraOAuthConfig({ app, tenantId }); + const { productionClientId, authorizationEndpoint } = await getEntraOAuthConfig({ app, tenantId }); const loginHint = connectionInfo.loginHint ? `login_hint=${encodeURIComponent(connectionInfo.loginHint)}&` : ''; const redirectUrl = `http://localhost:${connectionInfo.redirectPort || 8080}`; diff --git a/reverse_engineering/databaseService/helpers/connection.js b/reverse_engineering/databaseService/helpers/connection.js index e1196fc0..7d9e639d 100644 --- a/reverse_engineering/databaseService/helpers/connection.js +++ b/reverse_engineering/databaseService/helpers/connection.js @@ -25,7 +25,7 @@ async function getConnectionClient({ connectionInfo, logger, app }) { sslOptions, timeout, tenantId, - clientId: getEntraOAuthConfig({ app }).productionClientId, + clientId: (await getEntraOAuthConfig({ app })).productionClientId, }); default: return await sql.connect(connectionInfo.connectionString); diff --git a/reverse_engineering/helpers/entraOAuthConfig.js b/reverse_engineering/helpers/entraOAuthConfig.js index bfc29ffc..0d3ee3b0 100644 --- a/reverse_engineering/helpers/entraOAuthConfig.js +++ b/reverse_engineering/helpers/entraOAuthConfig.js @@ -31,7 +31,7 @@ const getLegacyEntraOAuthConfig = ({ tenantId } = {}) => ({ desktopTokenRequestHeaders: FALLBACK_DESKTOP_TOKEN_REQUEST_HEADERS, }); -const getEntraOAuthConfig = ({ app, tenantId } = {}) => { +const getEntraOAuthConfig = async ({ app, tenantId } = {}) => { const oauth = loadOauthModule({ app }); if (!oauth) { diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js index 320c26c8..92e083f9 100644 --- a/reverse_engineering/helpers/mfaAccessToken.js +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -35,7 +35,7 @@ const parseResponse = async response => { const exchangeAuthorizationCode = async ({ app, connectionInfo, logger } = {}) => { try { - const { productionClientId, tokenEndpoint, desktopTokenRequestHeaders } = getEntraOAuthConfig({ app }); + const { productionClientId, tokenEndpoint, desktopTokenRequestHeaders } = await getEntraOAuthConfig({ app }); const urlParams = new URLSearchParams(); urlParams.append('code', connectionInfo?.externalBrowserQuery?.code || ''); @@ -70,7 +70,7 @@ const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { return exchangeAuthorizationCode({ app, connectionInfo, logger }); } - const { productionClientId, authorizationEndpoint, tokenEndpoint } = oauth.getEntraIdOAuthConfig({ + const { productionClientId, authorizationEndpoint, tokenEndpoint } = await oauth.getEntraIdOAuthConfig({ tenantId: resolveAuthorizeTenant({ connectionInfo }), }); const { accessToken } = await oauth.browserLoginFlow({ From 7f1fbadb725eba53a5dfab90850551ec8fca6189 Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Mon, 26 Oct 2026 07:44:08 -0400 Subject: [PATCH 09/12] Fix the use of externalBrowserPort --- reverse_engineering/api.js | 3 +- reverse_engineering/helpers/mfaAccessToken.js | 14 ++++++--- .../helpers/mfaAccessToken.spec.js | 30 +++++++++++++++---- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index 4b8f38fb..947c3204 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -79,7 +79,8 @@ module.exports = { const tenantId = resolveAuthorizeTenant({ connectionInfo }); const { productionClientId, authorizationEndpoint } = await getEntraOAuthConfig({ app, tenantId }); const loginHint = connectionInfo.loginHint ? `login_hint=${encodeURIComponent(connectionInfo.loginHint)}&` : ''; - const redirectUrl = `http://localhost:${connectionInfo.redirectPort || 8080}`; + const redirectPort = connectionInfo.redirectPort ?? connectionInfo.externalBrowserPort ?? 8080; + const redirectUrl = `http://localhost:${redirectPort}`; cb(null, { proofKey: verifier, diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js index 92e083f9..3ae9a0ed 100644 --- a/reverse_engineering/helpers/mfaAccessToken.js +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -4,8 +4,13 @@ const { hckFetch } = require('@hackolade/fetch'); const { getEntraOAuthConfig, loadOauthModule, resolveAuthorizeTenant } = require('./entraOAuthConfig'); -const REDIRECT_URI = 'http://localhost:8080'; -const REDIRECT_PORT = 8080; +const DEFAULT_EXTERNAL_BROWSER_PORT = 8080; + +const resolveExternalBrowserPort = ({ connectionInfo = {} } = {}) => + connectionInfo.redirectPort ?? connectionInfo.externalBrowserPort ?? DEFAULT_EXTERNAL_BROWSER_PORT; + +const buildRedirectUri = ({ connectionInfo = {} } = {}) => + `http://localhost:${resolveExternalBrowserPort({ connectionInfo })}`; const getAuthorizationQuery = ({ connectionInfo = {} } = {}) => { const authorizationQuery = { @@ -40,7 +45,7 @@ const exchangeAuthorizationCode = async ({ app, connectionInfo, logger } = {}) = urlParams.append('code', connectionInfo?.externalBrowserQuery?.code || ''); urlParams.append('client_id', productionClientId); - urlParams.append('redirect_uri', REDIRECT_URI); + urlParams.append('redirect_uri', buildRedirectUri({ connectionInfo })); urlParams.append('grant_type', 'authorization_code'); urlParams.append('code_verifier', connectionInfo?.proofKey); @@ -73,11 +78,12 @@ const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { const { productionClientId, authorizationEndpoint, tokenEndpoint } = await oauth.getEntraIdOAuthConfig({ tenantId: resolveAuthorizeTenant({ connectionInfo }), }); + const redirectPort = resolveExternalBrowserPort({ connectionInfo }); const { accessToken } = await oauth.browserLoginFlow({ authorizationEndpoint, tokenEndpoint, clientId: productionClientId, - port: REDIRECT_PORT, + port: redirectPort, failOnPortConflict: true, authorizationQuery: getAuthorizationQuery({ connectionInfo }), }); diff --git a/reverse_engineering/helpers/mfaAccessToken.spec.js b/reverse_engineering/helpers/mfaAccessToken.spec.js index e26391ee..42f9f33d 100644 --- a/reverse_engineering/helpers/mfaAccessToken.spec.js +++ b/reverse_engineering/helpers/mfaAccessToken.spec.js @@ -32,11 +32,7 @@ registerHooks({ }, }); -const { - getMfaAccessToken, - canRequestUserToMachineAccessToken, - shouldUseExternalBrowser, -} = require('./mfaAccessToken'); +const { getMfaAccessToken, canRequestUserToMachineAccessToken, shouldUseExternalBrowser } = require('./mfaAccessToken'); const ENTRA_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; const OAUTH_NOT_EXPOSED_MESSAGE = 'The module @hackolade/oauth is not exposed by the application to the plugins!'; @@ -99,6 +95,30 @@ describe('getMfaAccessToken', { concurrency: false }, () => { ]); }); + it('uses externalBrowserPort from the connection for browserLoginFlow', async () => { + const calls = []; + const app = { + require() { + return { + getEntraIdOAuthConfig, + async browserLoginFlow(options) { + calls.push(options); + + return { accessToken: 'sql-access-token' }; + }, + }; + }, + }; + + await getMfaAccessToken({ + app, + connectionInfo: { externalBrowserPort: 8082 }, + logger: { log() {} }, + }); + + assert.equal(calls[0].port, 8082); + }); + it('uses the control-plane tenant when the connection tenant is empty and omits a missing login hint', async () => { const calls = []; const app = { From 186c62d6a000c2ea22fcb22696ad74cbc48b753d Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Mon, 26 Oct 2026 17:42:15 -0400 Subject: [PATCH 10/12] MFA oauth renaming --- reverse_engineering/api.js | 8 +++---- .../helpers/entraOAuthConfig.js | 10 ++++---- reverse_engineering/helpers/mfaAccessToken.js | 21 +++++++++------- .../helpers/mfaAccessToken.spec.js | 24 +++++++++---------- 4 files changed, 32 insertions(+), 31 deletions(-) diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index 947c3204..5276327e 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -18,8 +18,8 @@ const getOptionsFromConnectionInfo = require('./helpers/getOptionsFromConnection const { adaptJsonSchema } = require('./helpers/adaptJsonSchema'); const { parseConnectionString } = require('./helpers/parseConnectionString'); const { prepareError } = require('./databaseService/helpers/errorService'); -const { canRequestUserToMachineAccessToken, shouldUseExternalBrowser } = require('./helpers/mfaAccessToken'); -const { getEntraOAuthConfig, resolveAuthorizeTenant } = require('./helpers/entraOAuthConfig'); +const { canSignInWithStudio, shouldUseExternalBrowser } = require('./helpers/mfaAccessToken'); +const { getLegacyEntraOAuthConfig, resolveAuthorizeTenant } = require('./helpers/entraOAuthConfig'); module.exports = { async connect(connectionInfo, logger, callback, app) { @@ -68,7 +68,7 @@ module.exports = { }, async getExternalBrowserUrl(connectionInfo, logger, cb, app) { - if (canRequestUserToMachineAccessToken({ app, connectionInfo })) { + if (canSignInWithStudio({ app, connectionInfo })) { cb(null, {}); return; } @@ -77,7 +77,7 @@ module.exports = { const base64Digest = crypto.createHash('sha256').update(verifier).digest('base64'); const challenge = base64url.fromBase64(base64Digest); const tenantId = resolveAuthorizeTenant({ connectionInfo }); - const { productionClientId, authorizationEndpoint } = await getEntraOAuthConfig({ app, tenantId }); + const { productionClientId, authorizationEndpoint } = getLegacyEntraOAuthConfig({ tenantId }); const loginHint = connectionInfo.loginHint ? `login_hint=${encodeURIComponent(connectionInfo.loginHint)}&` : ''; const redirectPort = connectionInfo.redirectPort ?? connectionInfo.externalBrowserPort ?? 8080; const redirectUrl = `http://localhost:${redirectPort}`; diff --git a/reverse_engineering/helpers/entraOAuthConfig.js b/reverse_engineering/helpers/entraOAuthConfig.js index 0d3ee3b0..942e6e1b 100644 --- a/reverse_engineering/helpers/entraOAuthConfig.js +++ b/reverse_engineering/helpers/entraOAuthConfig.js @@ -4,7 +4,6 @@ const OAUTH_MODULE = '@hackolade/oauth'; const OAUTH_NOT_EXPOSED_MESSAGE = `The module ${OAUTH_MODULE} is not exposed by the application to the plugins!`; const FALLBACK_PRODUCTION_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; const FALLBACK_TOKEN_ENDPOINT = 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token'; -const FALLBACK_AUTHORIZE_ENDPOINT = 'https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize'; const FALLBACK_DESKTOP_TOKEN_REQUEST_HEADERS = { Origin: 'http://localhost' }; const loadOauthModule = ({ app } = {}) => { @@ -22,11 +21,9 @@ const loadOauthModule = ({ app } = {}) => { const resolveAuthorizeTenant = ({ connectionInfo = {} } = {}) => connectionInfo.connectionTenantId || connectionInfo.tenantId || 'common'; -const getLegacyEntraOAuthConfig = ({ tenantId } = {}) => ({ +const getLegacyEntraOAuthConfig = ({ tenantId = 'common' } = {}) => ({ productionClientId: FALLBACK_PRODUCTION_CLIENT_ID, - authorizationEndpoint: tenantId - ? `https://login.microsoftonline.com/${tenantId}/oauth2/authorize` - : FALLBACK_AUTHORIZE_ENDPOINT, + authorizationEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/authorize`, tokenEndpoint: FALLBACK_TOKEN_ENDPOINT, desktopTokenRequestHeaders: FALLBACK_DESKTOP_TOKEN_REQUEST_HEADERS, }); @@ -35,7 +32,7 @@ const getEntraOAuthConfig = async ({ app, tenantId } = {}) => { const oauth = loadOauthModule({ app }); if (!oauth) { - return getLegacyEntraOAuthConfig({ tenantId }); + return { productionClientId: FALLBACK_PRODUCTION_CLIENT_ID }; } return oauth.getEntraIdOAuthConfig(tenantId === undefined ? {} : { tenantId }); @@ -44,5 +41,6 @@ const getEntraOAuthConfig = async ({ app, tenantId } = {}) => { module.exports = { loadOauthModule, resolveAuthorizeTenant, + getLegacyEntraOAuthConfig, getEntraOAuthConfig, }; diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js index 3ae9a0ed..44a7eefb 100644 --- a/reverse_engineering/helpers/mfaAccessToken.js +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -2,7 +2,7 @@ const { hckFetch } = require('@hackolade/fetch'); -const { getEntraOAuthConfig, loadOauthModule, resolveAuthorizeTenant } = require('./entraOAuthConfig'); +const { getLegacyEntraOAuthConfig, loadOauthModule, resolveAuthorizeTenant } = require('./entraOAuthConfig'); const DEFAULT_EXTERNAL_BROWSER_PORT = 8080; @@ -38,9 +38,12 @@ const parseResponse = async response => { return response.json(); }; -const exchangeAuthorizationCode = async ({ app, connectionInfo, logger } = {}) => { +const exchangeAuthorizationCode = async ({ connectionInfo, logger } = {}) => { try { - const { productionClientId, tokenEndpoint, desktopTokenRequestHeaders } = await getEntraOAuthConfig({ app }); + const tenantId = resolveAuthorizeTenant({ connectionInfo }); + const { productionClientId, tokenEndpoint, desktopTokenRequestHeaders } = getLegacyEntraOAuthConfig({ + tenantId, + }); const urlParams = new URLSearchParams(); urlParams.append('code', connectionInfo?.externalBrowserQuery?.code || ''); @@ -72,19 +75,19 @@ const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { const oauth = loadOauthModule({ app }); if (!oauth) { - return exchangeAuthorizationCode({ app, connectionInfo, logger }); + return exchangeAuthorizationCode({ connectionInfo, logger }); } const { productionClientId, authorizationEndpoint, tokenEndpoint } = await oauth.getEntraIdOAuthConfig({ tenantId: resolveAuthorizeTenant({ connectionInfo }), }); const redirectPort = resolveExternalBrowserPort({ connectionInfo }); - const { accessToken } = await oauth.browserLoginFlow({ + const { accessToken } = await oauth.signInWithBrowser({ authorizationEndpoint, tokenEndpoint, clientId: productionClientId, port: redirectPort, - failOnPortConflict: true, + failOnPortConflict: false, authorizationQuery: getAuthorizationQuery({ connectionInfo }), }); @@ -97,7 +100,7 @@ const getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { const isEntraMfa = ({ connectionInfo } = {}) => connectionInfo?.authMethod === 'Azure Active Directory (MFA)'; -const canRequestUserToMachineAccessToken = ({ app, connectionInfo } = {}) => { +const canSignInWithStudio = ({ app, connectionInfo } = {}) => { if (!isEntraMfa({ connectionInfo })) { return false; } @@ -106,10 +109,10 @@ const canRequestUserToMachineAccessToken = ({ app, connectionInfo } = {}) => { }; const shouldUseExternalBrowser = ({ app, connectionInfo } = {}) => - isEntraMfa({ connectionInfo }) && !canRequestUserToMachineAccessToken({ app, connectionInfo }); + isEntraMfa({ connectionInfo }) && !loadOauthModule({ app }); module.exports = { getMfaAccessToken, - canRequestUserToMachineAccessToken, + canSignInWithStudio, shouldUseExternalBrowser, }; diff --git a/reverse_engineering/helpers/mfaAccessToken.spec.js b/reverse_engineering/helpers/mfaAccessToken.spec.js index 42f9f33d..72917be7 100644 --- a/reverse_engineering/helpers/mfaAccessToken.spec.js +++ b/reverse_engineering/helpers/mfaAccessToken.spec.js @@ -32,7 +32,7 @@ registerHooks({ }, }); -const { getMfaAccessToken, canRequestUserToMachineAccessToken, shouldUseExternalBrowser } = require('./mfaAccessToken'); +const { getMfaAccessToken, canSignInWithStudio, shouldUseExternalBrowser } = require('./mfaAccessToken'); const ENTRA_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; const OAUTH_NOT_EXPOSED_MESSAGE = 'The module @hackolade/oauth is not exposed by the application to the plugins!'; @@ -57,7 +57,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { return { getEntraIdOAuthConfig, - async browserLoginFlow(options) { + async signInWithBrowser(options) { calls.push(options); return { accessToken: 'sql-access-token' }; @@ -84,7 +84,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { tokenEndpoint: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', clientId: ENTRA_CLIENT_ID, port: 8080, - failOnPortConflict: true, + failOnPortConflict: false, authorizationQuery: { prompt: 'select_account', response_mode: 'query', @@ -95,13 +95,13 @@ describe('getMfaAccessToken', { concurrency: false }, () => { ]); }); - it('uses externalBrowserPort from the connection for browserLoginFlow', async () => { + it('uses externalBrowserPort from the connection for signInWithBrowser', async () => { const calls = []; const app = { require() { return { getEntraIdOAuthConfig, - async browserLoginFlow(options) { + async signInWithBrowser(options) { calls.push(options); return { accessToken: 'sql-access-token' }; @@ -125,7 +125,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { require() { return { getEntraIdOAuthConfig, - async browserLoginFlow(options) { + async signInWithBrowser(options) { calls.push(options); return { accessToken: 'sql-access-token' }; @@ -160,7 +160,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { require() { return { getEntraIdOAuthConfig, - async browserLoginFlow(options) { + async signInWithBrowser(options) { calls.push(options); return { accessToken: 'sql-access-token' }; @@ -183,7 +183,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { require() { return { getEntraIdOAuthConfig, - async browserLoginFlow() { + async signInWithBrowser() { return {}; }, }; @@ -226,7 +226,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { require() { return { getEntraIdOAuthConfig, - async browserLoginFlow() { + async signInWithBrowser() { throw failure; }, }; @@ -314,7 +314,7 @@ describe('getMfaAccessToken', { concurrency: false }, () => { }); }); -describe('canRequestUserToMachineAccessToken', { concurrency: false }, () => { +describe('canSignInWithStudio', { concurrency: false }, () => { const mfaConnectionInfo = { authMethod: 'Azure Active Directory (MFA)' }; it('uses the user-to-machine call when the OAuth module is present', () => { @@ -324,7 +324,7 @@ describe('canRequestUserToMachineAccessToken', { concurrency: false }, () => { }, }; - assert.equal(canRequestUserToMachineAccessToken({ app, connectionInfo: mfaConnectionInfo }), true); + assert.equal(canSignInWithStudio({ app, connectionInfo: mfaConnectionInfo }), true); }); it('keeps the external browser when the OAuth module is not exposed', () => { @@ -334,7 +334,7 @@ describe('canRequestUserToMachineAccessToken', { concurrency: false }, () => { }, }; - assert.equal(canRequestUserToMachineAccessToken({ app, connectionInfo: mfaConnectionInfo }), false); + assert.equal(canSignInWithStudio({ app, connectionInfo: mfaConnectionInfo }), false); }); }); From 4c1505f12a55d4c199e78654d1e1fcb8f7d60a0b Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Mon, 26 Oct 2026 17:43:34 -0400 Subject: [PATCH 11/12] removed spec file --- .../helpers/mfaAccessToken.spec.js | 377 ------------------ 1 file changed, 377 deletions(-) delete mode 100644 reverse_engineering/helpers/mfaAccessToken.spec.js diff --git a/reverse_engineering/helpers/mfaAccessToken.spec.js b/reverse_engineering/helpers/mfaAccessToken.spec.js deleted file mode 100644 index 72917be7..00000000 --- a/reverse_engineering/helpers/mfaAccessToken.spec.js +++ /dev/null @@ -1,377 +0,0 @@ -'use strict'; - -const { registerHooks } = require('node:module'); -const assert = require('node:assert/strict'); -const { describe, it } = require('node:test'); - -globalThis.entraMfaFetch = async () => { - throw new Error('hckFetch should not be called'); -}; - -registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier !== '@hackolade/fetch') { - return nextResolve(specifier, context); - } - - return { - url: 'file:///hackolade-fetch-stub', - shortCircuit: true, - }; - }, - load(url, context, nextLoad) { - if (url !== 'file:///hackolade-fetch-stub') { - return nextLoad(url, context); - } - - return { - format: 'commonjs', - source: 'exports.hckFetch = (...args) => globalThis.entraMfaFetch(...args);', - shortCircuit: true, - }; - }, -}); - -const { getMfaAccessToken, canSignInWithStudio, shouldUseExternalBrowser } = require('./mfaAccessToken'); - -const ENTRA_CLIENT_ID = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; -const OAUTH_NOT_EXPOSED_MESSAGE = 'The module @hackolade/oauth is not exposed by the application to the plugins!'; - -const getEntraIdOAuthConfig = ({ tenantId } = {}) => ({ - productionClientId: ENTRA_CLIENT_ID, - testingClientId: 'f28311c1-422d-4c77-8bbb-ff2fa1b84b7a', - authorizationEndpoint: tenantId - ? `https://login.microsoftonline.com/${tenantId}/oauth2/authorize` - : 'https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize', - tokenEndpoint: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', - adminConsentBase: 'https://login.microsoftonline.com/organizations/adminconsent', - desktopTokenRequestHeaders: { Origin: 'http://localhost' }, -}); - -describe('getMfaAccessToken', { concurrency: false }, () => { - it('asks Studio for a user-to-machine token with the Entra authorize request', async () => { - const calls = []; - const app = { - require(name) { - assert.equal(name, '@hackolade/oauth'); - - return { - getEntraIdOAuthConfig, - async signInWithBrowser(options) { - calls.push(options); - - return { accessToken: 'sql-access-token' }; - }, - }; - }, - }; - - const token = await getMfaAccessToken({ - app, - connectionInfo: { - connectionTenantId: 'saved-directory', - tenantId: 'control-plane-tenant', - loginHint: 'user name@example.com', - }, - logger: { log() {} }, - }); - - assert.equal(token, 'sql-access-token'); - assert.equal('scope' in calls[0], false); - assert.deepEqual(calls, [ - { - authorizationEndpoint: 'https://login.microsoftonline.com/saved-directory/oauth2/authorize', - tokenEndpoint: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', - clientId: ENTRA_CLIENT_ID, - port: 8080, - failOnPortConflict: false, - authorizationQuery: { - prompt: 'select_account', - response_mode: 'query', - resource: 'https://database.windows.net/', - login_hint: 'user name@example.com', - }, - }, - ]); - }); - - it('uses externalBrowserPort from the connection for signInWithBrowser', async () => { - const calls = []; - const app = { - require() { - return { - getEntraIdOAuthConfig, - async signInWithBrowser(options) { - calls.push(options); - - return { accessToken: 'sql-access-token' }; - }, - }; - }, - }; - - await getMfaAccessToken({ - app, - connectionInfo: { externalBrowserPort: 8082 }, - logger: { log() {} }, - }); - - assert.equal(calls[0].port, 8082); - }); - - it('uses the control-plane tenant when the connection tenant is empty and omits a missing login hint', async () => { - const calls = []; - const app = { - require() { - return { - getEntraIdOAuthConfig, - async signInWithBrowser(options) { - calls.push(options); - - return { accessToken: 'sql-access-token' }; - }, - }; - }, - }; - - await getMfaAccessToken({ - app, - connectionInfo: { - connectionTenantId: '', - tenantId: 'control-plane-tenant', - }, - logger: { log() {} }, - }); - - assert.equal( - calls[0].authorizationEndpoint, - 'https://login.microsoftonline.com/control-plane-tenant/oauth2/authorize', - ); - assert.deepEqual(calls[0].authorizationQuery, { - prompt: 'select_account', - response_mode: 'query', - resource: 'https://database.windows.net/', - }); - }); - - it('uses the common tenant when no tenant id is set', async () => { - const calls = []; - const app = { - require() { - return { - getEntraIdOAuthConfig, - async signInWithBrowser(options) { - calls.push(options); - - return { accessToken: 'sql-access-token' }; - }, - }; - }, - }; - - await getMfaAccessToken({ - app, - connectionInfo: {}, - logger: { log() {} }, - }); - - assert.equal(calls[0].authorizationEndpoint, 'https://login.microsoftonline.com/common/oauth2/authorize'); - }); - - it('rejects when Studio does not return an access token', async () => { - const app = { - require() { - return { - getEntraIdOAuthConfig, - async signInWithBrowser() { - return {}; - }, - }; - }, - }; - - await assert.rejects( - () => - getMfaAccessToken({ - app, - connectionInfo: { connectionTenantId: 'saved-directory' }, - logger: { log() {} }, - }), - { message: 'Entra token response did not include an access token.' }, - ); - }); - - it('rejects when loading the OAuth module fails for another reason', async () => { - const failure = new Error('oauth crashed'); - const app = { - require() { - throw failure; - }, - }; - - await assert.rejects( - () => - getMfaAccessToken({ - app, - connectionInfo: {}, - logger: { log() {} }, - }), - error => error === failure, - ); - }); - - it('rejects when the OAuth module fails to return a token', async () => { - const failure = new Error('Entra rejected the login'); - const app = { - require() { - return { - getEntraIdOAuthConfig, - async signInWithBrowser() { - throw failure; - }, - }; - }, - }; - - await assert.rejects( - () => - getMfaAccessToken({ - app, - connectionInfo: {}, - logger: { log() {} }, - }), - error => error === failure, - ); - }); - - it('exchanges the authorization code and proof key when the OAuth module is not exposed', async () => { - const requests = []; - globalThis.entraMfaFetch = async (url, options) => { - requests.push({ url, body: String(options.body) }); - - return { - status: 200, - json: async () => ({ access_token: 'exchanged-token' }), - }; - }; - const app = { - require() { - throw new Error(OAUTH_NOT_EXPOSED_MESSAGE); - }, - }; - - const token = await getMfaAccessToken({ - app, - connectionInfo: { - externalBrowserQuery: { code: 'auth-code' }, - proofKey: 'proof-key', - }, - logger: { log() {} }, - }); - - assert.equal(token, 'exchanged-token'); - assert.deepEqual(requests, [ - { - url: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', - body: 'code=auth-code&client_id=0dc36597-bc44-49f8-a4a7-ae5401959b85&redirect_uri=http%3A%2F%2Flocalhost%3A8080&grant_type=authorization_code&code_verifier=proof-key', - }, - ]); - }); - - it('logs a code exchange failure and returns an empty token', async () => { - const failure = new Error('network down'); - const entries = []; - globalThis.entraMfaFetch = async () => { - throw failure; - }; - const app = { - require() { - throw new Error(OAUTH_NOT_EXPOSED_MESSAGE); - }, - }; - - const token = await getMfaAccessToken({ - app, - connectionInfo: { - externalBrowserQuery: { code: 'auth-code' }, - proofKey: 'proof-key', - }, - logger: { - log(level, data, message) { - entries.push({ level, data, message }); - }, - }, - }); - - assert.equal(token, ''); - assert.deepEqual(entries, [ - { - level: 'error', - data: { message: 'network down', stack: failure.stack, error: failure }, - message: 'MFA auth error', - }, - ]); - }); -}); - -describe('canSignInWithStudio', { concurrency: false }, () => { - const mfaConnectionInfo = { authMethod: 'Azure Active Directory (MFA)' }; - - it('uses the user-to-machine call when the OAuth module is present', () => { - const app = { - require() { - return {}; - }, - }; - - assert.equal(canSignInWithStudio({ app, connectionInfo: mfaConnectionInfo }), true); - }); - - it('keeps the external browser when the OAuth module is not exposed', () => { - const app = { - require() { - throw new Error(OAUTH_NOT_EXPOSED_MESSAGE); - }, - }; - - assert.equal(canSignInWithStudio({ app, connectionInfo: mfaConnectionInfo }), false); - }); -}); - -describe('shouldUseExternalBrowser', { concurrency: false }, () => { - const mfaConnectionInfo = { authMethod: 'Azure Active Directory (MFA)' }; - - it('opens SQL when the OAuth module is present', () => { - const app = { - require() { - return {}; - }, - }; - - assert.equal(shouldUseExternalBrowser({ app, connectionInfo: mfaConnectionInfo }), false); - }); - - it('keeps the external browser when the OAuth module is not exposed', () => { - const app = { - require() { - throw new Error(OAUTH_NOT_EXPOSED_MESSAGE); - }, - }; - - assert.equal(shouldUseExternalBrowser({ app, connectionInfo: mfaConnectionInfo }), true); - }); - - it('surfaces any other failure while loading the OAuth module', () => { - const failure = new Error('oauth crashed'); - const app = { - require() { - throw failure; - }, - }; - - assert.throws( - () => shouldUseExternalBrowser({ app, connectionInfo: mfaConnectionInfo }), - error => error === failure, - ); - }); -}); From 5a6db10ab952da4b03aaa7de07ce58a01ab1d315 Mon Sep 17 00:00:00 2001 From: massintha azamoum Date: Tue, 27 Oct 2026 08:58:29 -0400 Subject: [PATCH 12/12] typo fix --- .../connectionSettingsModalConfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json b/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json index e656fc08..8eba6db9 100644 --- a/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json +++ b/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json @@ -127,7 +127,7 @@ }, { "value": "Azure Active Directory (MFA)", - "label": "Microsoft Entra ID via authorization code flow (PCKE)" + "label": "Microsoft Entra ID via authorization code flow (PKCE)" }, { "value": "Azure Active Directory (Username / Password)",