Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion forward_engineering/api/testConnection.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "SQLServer",
"version": "0.2.54",
"version": "0.2.55",
"author": "hackolade",
"engines": {
"hackolade": "7.7.10",
Expand Down Expand Up @@ -104,4 +104,4 @@
"uuid": "14.0.0",
"brace-expansion": "5.0.12"
}
}
}
19 changes: 14 additions & 5 deletions reverse_engineering/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -28,6 +30,7 @@ module.exports = {
connectionInfo,
logger,
sshService: app.require('@hackolade/ssh-service'),
app,
});
}

Expand All @@ -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);
Expand All @@ -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/`,
});
},

Expand Down
5 changes: 3 additions & 2 deletions reverse_engineering/clientManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -57,6 +57,7 @@ class ClientManager {
sshService,
attempts: attempts + 1,
logger,
app,
});
}

Expand Down
55 changes: 8 additions & 47 deletions reverse_engineering/databaseService/helpers/connection.js
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -17,15 +17,15 @@ 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,
userName,
sslOptions,
timeout,
tenantId,
clientId,
clientId: (await getEntraOAuthConfig({ app })).productionClientId,
});
default:
return await sql.connect(connectionInfo.connectionString);
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions reverse_engineering/helpers/entraOAuthConfig.js
Original file line number Diff line number Diff line change
@@ -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,
};
118 changes: 118 additions & 0 deletions reverse_engineering/helpers/mfaAccessToken.js
Original file line number Diff line number Diff line change
@@ -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,
};