diff --git a/forward_engineering/api/testConnection.js b/forward_engineering/api/testConnection.js index 93477ea6..5eb3546f 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 { shouldUseExternalBrowser } = 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 (shouldUseExternalBrowser({ app, connectionInfo })) { await getExternalBrowserUrlLocal(connectionInfo, logger, callback, app); } else { const client = await connect(connectionInfo, logger, () => {}, app); 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 63ecb2f3..a6aa2551 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", @@ -104,4 +104,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 cadcd554..5276327e 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -18,6 +18,8 @@ const getOptionsFromConnectionInfo = require('./helpers/getOptionsFromConnection const { adaptJsonSchema } = require('./helpers/adaptJsonSchema'); const { parseConnectionString } = require('./helpers/parseConnectionString'); const { prepareError } = require('./databaseService/helpers/errorService'); +const { canSignInWithStudio, shouldUseExternalBrowser } = require('./helpers/mfaAccessToken'); +const { getLegacyEntraOAuthConfig, resolveAuthorizeTenant } = require('./helpers/entraOAuthConfig'); module.exports = { async connect(connectionInfo, logger, callback, app) { @@ -28,6 +30,7 @@ module.exports = { connectionInfo, logger, sshService: app.require('@hackolade/ssh-service'), + app, }); } @@ -42,7 +45,7 @@ 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, app); } else { const client = await this.connect(connectionInfo, logger, () => {}, app); @@ -65,17 +68,23 @@ module.exports = { }, async getExternalBrowserUrl(connectionInfo, logger, cb, app) { + if (canSignInWithStudio({ app, connectionInfo })) { + cb(null, {}); + return; + } + 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 } = getLegacyEntraOAuthConfig({ 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, - 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/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/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)", diff --git a/reverse_engineering/databaseService/helpers/connection.js b/reverse_engineering/databaseService/helpers/connection.js index 3c5e5a6b..7d9e639d 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 { hckFetch } = require('@hackolade/fetch'); +const { getMfaAccessToken } = require('../../helpers/mfaAccessToken'); +const { getEntraOAuthConfig, resolveAuthorizeTenant } = require('../../helpers/entraOAuthConfig'); 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'; + const tenantId = resolveAuthorizeTenant({ connectionInfo }); const sslOptions = getSslConfig({ connectionInfo }); const timeout = Number(connectionInfo.queryRequestTimeout) || QUERY_REQUEST_TIMEOUT; @@ -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, @@ -25,7 +25,7 @@ async function getConnectionClient({ connectionInfo, logger }) { sslOptions, timeout, tenantId, - clientId, + clientId: (await getEntraOAuthConfig({ app })).productionClientId, }); default: return await sql.connect(connectionInfo.connectionString); @@ -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/entraOAuthConfig.js b/reverse_engineering/helpers/entraOAuthConfig.js new file mode 100644 index 00000000..942e6e1b --- /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_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 = 'common' } = {}) => ({ + productionClientId: FALLBACK_PRODUCTION_CLIENT_ID, + authorizationEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/authorize`, + tokenEndpoint: FALLBACK_TOKEN_ENDPOINT, + desktopTokenRequestHeaders: FALLBACK_DESKTOP_TOKEN_REQUEST_HEADERS, +}); + +const getEntraOAuthConfig = async ({ app, tenantId } = {}) => { + const oauth = loadOauthModule({ app }); + + if (!oauth) { + return { productionClientId: FALLBACK_PRODUCTION_CLIENT_ID }; + } + + return oauth.getEntraIdOAuthConfig(tenantId === undefined ? {} : { tenantId }); +}; + +module.exports = { + loadOauthModule, + resolveAuthorizeTenant, + getLegacyEntraOAuthConfig, + getEntraOAuthConfig, +}; diff --git a/reverse_engineering/helpers/mfaAccessToken.js b/reverse_engineering/helpers/mfaAccessToken.js new file mode 100644 index 00000000..44a7eefb --- /dev/null +++ b/reverse_engineering/helpers/mfaAccessToken.js @@ -0,0 +1,118 @@ +'use strict'; + +const { hckFetch } = require('@hackolade/fetch'); + +const { getLegacyEntraOAuthConfig, loadOauthModule, resolveAuthorizeTenant } = require('./entraOAuthConfig'); + +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 = { + 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 tenantId = resolveAuthorizeTenant({ connectionInfo }); + const { productionClientId, tokenEndpoint, desktopTokenRequestHeaders } = getLegacyEntraOAuthConfig({ + tenantId, + }); + const urlParams = new URLSearchParams(); + + urlParams.append('code', connectionInfo?.externalBrowserQuery?.code || ''); + urlParams.append('client_id', productionClientId); + urlParams.append('redirect_uri', buildRedirectUri({ connectionInfo })); + 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({ connectionInfo, logger }); + } + + const { productionClientId, authorizationEndpoint, tokenEndpoint } = await oauth.getEntraIdOAuthConfig({ + tenantId: resolveAuthorizeTenant({ connectionInfo }), + }); + const redirectPort = resolveExternalBrowserPort({ connectionInfo }); + const { accessToken } = await oauth.signInWithBrowser({ + authorizationEndpoint, + tokenEndpoint, + clientId: productionClientId, + port: redirectPort, + failOnPortConflict: false, + 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 canSignInWithStudio = ({ app, connectionInfo } = {}) => { + if (!isEntraMfa({ connectionInfo })) { + return false; + } + + return Boolean(loadOauthModule({ app })); +}; + +const shouldUseExternalBrowser = ({ app, connectionInfo } = {}) => + isEntraMfa({ connectionInfo }) && !loadOauthModule({ app }); + +module.exports = { + getMfaAccessToken, + canSignInWithStudio, + shouldUseExternalBrowser, +};