diff --git a/forward_engineering/api/testConnection.js b/forward_engineering/api/testConnection.js index 0b25832..748ad5a 100644 --- a/forward_engineering/api/testConnection.js +++ b/forward_engineering/api/testConnection.js @@ -1,13 +1,14 @@ const { logInfo } = require('../../reverse_engineering/helpers/logInfo'); +const { shouldUseExternalBrowser } = require('../../reverse_engineering/helpers/mfaAccessToken'); const { getExternalBrowserUrl, connect } = require('../../reverse_engineering/api'); async function testConnection(connectionInfo, logger, callback, app) { try { logInfo('Test connection', connectionInfo, logger); - if (connectionInfo.authMethod === 'Azure Active Directory (MFA)') { + if (shouldUseExternalBrowser({ app, connectionInfo })) { await getExternalBrowserUrl(connectionInfo, logger, callback); } else { - await connect(connectionInfo, logger); + await connect(connectionInfo, logger, undefined, app); } callback(null); } catch (error) { diff --git a/forward_engineering/helpers/applyToInstanceHelper.js b/forward_engineering/helpers/applyToInstanceHelper.js index 9ece971..a80409f 100644 --- a/forward_engineering/helpers/applyToInstanceHelper.js +++ b/forward_engineering/helpers/applyToInstanceHelper.js @@ -7,7 +7,7 @@ const applyToInstance = async (connectionInfo, logger, app) => { const async = app.require('async'); try { - const client = await connect(connectionInfo, logger); + const client = await connect(connectionInfo, logger, undefined, app); if (!client.config.database) { throw new Error('No database specified'); } diff --git a/package-lock.json b/package-lock.json index 910417f..82b6a4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "Synapse", - "version": "0.2.35", + "version": "0.2.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Synapse", - "version": "0.2.35", + "version": "0.2.36", "dependencies": { "@hackolade/fetch": "1.3.0", "base64url": "3.0.1", diff --git a/package.json b/package.json index 060e267..7d9d4b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Synapse", - "version": "0.2.35", + "version": "0.2.36", "author": "hackolade", "engines": { "hackolade": "6.1.2", @@ -80,4 +80,4 @@ "uuid": "14.0.0", "brace-expansion": "5.0.12" } -} \ No newline at end of file +} diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index 9324bec..2ecbdc7 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -12,6 +12,8 @@ const { getCollectionsRelationships, } = require('./reverseEngineeringService/reverseEngineeringService'); const { logInfo } = require('./helpers/logInfo'); +const { canRequestUserToMachineAccessToken, shouldUseExternalBrowser } = require('./helpers/mfaAccessToken'); +const { getEntraOAuthConfig, resolveAuthorizeTenant } = require('./helpers/entraOAuthConfig'); const filterRelationships = require('./helpers/filterRelationships'); const getOptionsFromConnectionInfo = require('./helpers/getOptionsFromConnectionInfo'); const getAdditionalAccountInfo = require('./helpers/getAdditionalAccountInfo'); @@ -22,7 +24,7 @@ module.exports = { async connect(connectionInfo, logger, callback, app) { const client = getClient(); if (!client) { - await setClient(connectionInfo, logger); + await setClient(connectionInfo, logger, app); return getClient(); } @@ -37,10 +39,10 @@ module.exports = { async testConnection(connectionInfo, logger, callback, app) { try { logInfo('Test connection', connectionInfo, logger); - if (connectionInfo.authMethod === 'Azure Active Directory (MFA)') { + if (shouldUseExternalBrowser({ app, connectionInfo })) { await this.getExternalBrowserUrl(connectionInfo, logger, callback); } else { - await this.connect(connectionInfo, logger); + await this.connect(connectionInfo, logger, undefined, app); } callback(null); } catch (error) { @@ -59,18 +61,23 @@ module.exports = { }, async getExternalBrowserUrl(connectionInfo, logger, cb, app) { + if (canRequestUserToMachineAccessToken({ app, connectionInfo })) { + cb(null, {}); + return; + } + logInfo('Get external browser URL', connectionInfo, logger); 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 } = await 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/`, }); }, @@ -85,7 +92,7 @@ module.exports = { async getDbCollectionsNames(connectionInfo, logger, callback, app) { try { logInfo('Retrieving databases and tables information', connectionInfo, logger); - const client = await this.connect(connectionInfo, logger); + const client = await this.connect(connectionInfo, logger, undefined, app); if (!client.config.database) { throw new Error('No database specified'); } diff --git a/reverse_engineering/connectionState.js b/reverse_engineering/connectionState.js index 7b2c208..be6f767 100644 --- a/reverse_engineering/connectionState.js +++ b/reverse_engineering/connectionState.js @@ -4,9 +4,9 @@ module.exports = { _client: null, _connectionInfo: {}, getClient: () => this._client, - setClient: async (connectionInfo, logger) => { + setClient: async (connectionInfo, logger, app) => { this._connectionInfo = connectionInfo; - this._client = await getConnectionClient(connectionInfo, logger); + this._client = await getConnectionClient(connectionInfo, logger, app); }, getConnectionInfo: () => this._connectionInfo, clearClient: () => (this._client = null), diff --git a/reverse_engineering/databaseService/databaseService.js b/reverse_engineering/databaseService/databaseService.js index e585f22..93127d5 100644 --- a/reverse_engineering/databaseService/databaseService.js +++ b/reverse_engineering/databaseService/databaseService.js @@ -4,6 +4,7 @@ const { getObjectsFromDatabase, getNewConnectionClientByDb } = require('./helper const getSampleDocSize = require('../helpers/getSampleDocSize'); const { logAuthTokenInfo } = require('../helpers/logInfo'); const { getConnection } = require('./helpers/connection'); +const { getEntraOAuthConfig, resolveAuthorizeTenant } = require('../helpers/entraOAuthConfig'); const { progress } = require('../helpers/logInfo'); const { getPartitionsSubQueryForRetrievingTheTablesSelectedByTheUser, @@ -15,13 +16,13 @@ const { parseProcedure } = require('./helpers/parsers/parseProcedure'); const QUERY_REQUEST_TIMEOUT = 60000; -const getConnectionClient = async (connectionInfo, logger) => { +const getConnectionClient = async (connectionInfo, logger, app) => { const hostName = getHostName(connectionInfo.host); const userName = isEmail(connectionInfo.userName) && hostName ? `${connectionInfo.userName}@${hostName}` : connectionInfo.userName; - const tenantId = connectionInfo.connectionTenantId || connectionInfo.tenantId || 'common'; + const tenantId = resolveAuthorizeTenant({ connectionInfo }); const queryRequestTimeout = Number(connectionInfo.queryRequestTimeout) || QUERY_REQUEST_TIMEOUT; const commonConfig = { @@ -36,7 +37,10 @@ const getConnectionClient = async (connectionInfo, logger) => { password: connectionInfo.userPassword, }; - const clientId = '0dc36597-bc44-49f8-a4a7-ae5401959b85'; + const clientId = + connectionInfo.authMethod === 'Azure Active Directory (Username / Password)' + ? (await getEntraOAuthConfig({ app })).productionClientId + : undefined; const redirectUri = 'http://localhost:8080'; const connection = getConnection({ @@ -48,6 +52,7 @@ const getConnectionClient = async (connectionInfo, logger) => { clientId, redirectUri, logger, + app, }); return connection.connect(); diff --git a/reverse_engineering/databaseService/helpers/connection.js b/reverse_engineering/databaseService/helpers/connection.js index e5a069e..44e8052 100644 --- a/reverse_engineering/databaseService/helpers/connection.js +++ b/reverse_engineering/databaseService/helpers/connection.js @@ -1,8 +1,6 @@ -const { hckFetch } = require('@hackolade/fetch'); const sql = require('mssql'); const { logAuthTokenInfo, logConnectionHostAndUsername } = require('../../helpers/logInfo'); -const { prepareError } = require('./errorService'); -const { parseResponse } = require('../../helpers/parseResponse'); +const { getMfaAccessToken } = require('../../helpers/mfaAccessToken'); class Connection { constructor({ logger }) { @@ -51,17 +49,19 @@ class UsernamePasswordConnection extends Connection { } class AzureActiveDirectoryMFAConnection extends Connection { - constructor({ connectionInfo, commonConfig, tenantId, clientId, redirectUri, logger }) { + constructor({ connectionInfo, commonConfig, logger, app }) { super({ logger }); this.connectionInfo = connectionInfo; this.commonConfig = commonConfig; - this.tenantId = tenantId; - this.clientId = clientId; - this.redirectUri = redirectUri; + this.app = app; } async connect() { - const token = await this.#getToken(); + const token = await getMfaAccessToken({ + app: this.app, + connectionInfo: this.connectionInfo, + logger: this.logger, + }); logAuthTokenInfo({ token, logger: this.logger }); logConnectionHostAndUsername({ authMethod: this.connectionInfo.authMethod, logger: this.logger }); return sql.connect({ @@ -78,39 +78,6 @@ class AzureActiveDirectoryMFAConnection extends Connection { }, }); } - - async #getToken() { - try { - const urlParams = new URLSearchParams(); - - urlParams.append('code', this.connectionInfo?.externalBrowserQuery?.code || ''); - urlParams.append('client_id', this.clientId); - urlParams.append('redirect_uri', this.redirectUri); - urlParams.append('grant_type', 'authorization_code'); - urlParams.append('code_verifier', this.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 responseData = await parseResponse(response); - - return responseData?.access_token || ''; - } catch (error) { - this.logger.log('error', { message: error.message, stack: error.stack, error }, 'MFA auth error'); - return ''; - } - } } class AzureActiveDirectoryUsernamePasswordConnection extends Connection { diff --git a/reverse_engineering/helpers/entraOAuthConfig.js b/reverse_engineering/helpers/entraOAuthConfig.js new file mode 100644 index 0000000..0d3ee3b --- /dev/null +++ b/reverse_engineering/helpers/entraOAuthConfig.js @@ -0,0 +1,48 @@ +'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 FALLBACK_DESKTOP_TOKEN_REQUEST_HEADERS = { Origin: 'http://localhost' }; + +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, + desktopTokenRequestHeaders: FALLBACK_DESKTOP_TOKEN_REQUEST_HEADERS, +}); + +const getEntraOAuthConfig = async ({ 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 new file mode 100644 index 0000000..14b5893 --- /dev/null +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -0,0 +1,101 @@ +'use strict'; + +const { hckFetch } = require('@hackolade/fetch'); + +const { parseResponse } = require('./parseResponse'); +const { getEntraOAuthConfig, loadOauthModule, resolveAuthorizeTenant } = require('./entraOAuthConfig'); + +const REDIRECT_URI = 'http://localhost:8080'; +const REDIRECT_PORT = 8080; + +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 exchangeAuthorizationCode = async ({ app, connectionInfo, logger } = {}) => { + try { + const { productionClientId, tokenEndpoint, desktopTokenRequestHeaders } = await getEntraOAuthConfig({ app }); + const urlParams = new URLSearchParams(); + + urlParams.append('code', connectionInfo?.externalBrowserQuery?.code || ''); + 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(tokenEndpoint, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + ...desktopTokenRequestHeaders, + }, + 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 getMfaAccessToken = async ({ app, connectionInfo, logger } = {}) => { + const oauth = loadOauthModule({ app }); + + if (!oauth) { + return exchangeAuthorizationCode({ app, connectionInfo, logger }); + } + + const { productionClientId, authorizationEndpoint, tokenEndpoint } = await oauth.getEntraIdOAuthConfig({ + tenantId: resolveAuthorizeTenant({ connectionInfo }), + }); + const { accessToken } = await oauth.browserLoginFlow({ + authorizationEndpoint, + tokenEndpoint, + clientId: productionClientId, + port: REDIRECT_PORT, + failOnPortConflict: true, + authorizationQuery: getAuthorizationQuery({ connectionInfo }), + }); + + if (!accessToken) { + throw new Error('Entra token response did not include an access token.'); + } + + return accessToken; +}; + +const isEntraMfa = ({ connectionInfo } = {}) => connectionInfo?.authMethod === 'Azure Active Directory (MFA)'; + +const canRequestUserToMachineAccessToken = ({ app, connectionInfo } = {}) => { + if (!isEntraMfa({ connectionInfo })) { + return false; + } + + 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 new file mode 100644 index 0000000..ae32970 --- /dev/null +++ b/reverse_engineering/helpers/mfaAccessToken.spec.js @@ -0,0 +1,353 @@ +'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, 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!'; + +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 browserLoginFlow(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', + }, + }, + ]); + }); + + 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 browserLoginFlow(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 browserLoginFlow(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 browserLoginFlow() { + 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 browserLoginFlow() { + 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('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)' }; + + 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, + ); + }); +});