Skip to content

Commit

Permalink
feat(vscode): Auto regenerate managed api connection keys (#4982)
Browse files Browse the repository at this point in the history
* introducing connectionKeys.ts for auto regenerate keys

* removed comment in pickFuncProcess

* jwtHelper compatible without atob and local setting file access fixes

* add logic for resolving connection ids

* added function to format settings before api call

* updated jwtHelper and workflow services and their imports, added unit tests and logging

* update changes to connection.ts

* changed workflow to export functions and enabled duration telemetry

* fixed typo in workflow

* fixed workflow utility

* feat(vscode): Auto regenerate managed api connection keys (#5147)

* introducing connectionKeys.ts for auto regenerate keys

* removed comment in pickFuncProcess

* jwtHelper compatible without atob and local setting file access fixes

* add logic for resolving connection ids

* added function to format settings before api call

* updated jwtHelper and workflow services and their imports, added unit tests and logging

* update changes to connection.ts

* changed workflow to export functions and enabled duration telemetry

* fixed typo in workflow

* fixed workflow utility

---------

Co-authored-by: Joaquim Malcampo <jmalcampo@microsoft.com>

* Solve issue with mocking libraries

* adjusted unit tests and telemetry settings in pickFuncProcess

---------

Co-authored-by: Joaquim Malcampo <jmalcampo@microsoft.com>
Co-authored-by: Carlos Emiliano Castro Trejo <ccastrotrejo@microsoft.com>
  • Loading branch information
3 people committed Jul 19, 2024
1 parent 831f3cf commit c31d440
Show file tree
Hide file tree
Showing 14 changed files with 252 additions and 37 deletions.
16 changes: 14 additions & 2 deletions apps/vs-code-designer/src/app/commands/pickFuncProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Platform, autoStartAzuriteSetting, defaultFuncPort, hostStartTaskName, pickProcessTimeoutSetting } from '../../constants';
import {
Platform,
autoStartAzuriteSetting,
verifyConnectionKeysSetting,
defaultFuncPort,
hostStartTaskName,
pickProcessTimeoutSetting,
} from '../../constants';
import { ext } from '../../extensionVariables';
import { localize } from '../../localize';
import { preDebugValidate } from '../debug/validatePreDebug';
import { verifyLocalConnectionKeys } from '../utils/appSettings/connectionKeys';
import { activateAzurite } from '../utils/azurite/activateAzurite';
import { getProjFiles } from '../utils/dotnet/dotnet';
import { getFuncPortFromTaskOrProject, isFuncHostTask, runningFuncTaskMap } from '../utils/funcCoreTools/funcHostTask';
Expand Down Expand Up @@ -37,6 +45,11 @@ export async function pickFuncProcess(context: IActionContext, debugConfig: vsco
await activateAzurite(context);
});
});
await callWithTelemetryAndErrorHandling(verifyConnectionKeysSetting, async (actionContext: IActionContext) => {
await runWithDurationTelemetry(actionContext, verifyConnectionKeysSetting, async () => {
await verifyLocalConnectionKeys(context);
});
});

const result: IPreDebugValidateResult = await preDebugValidate(context, debugConfig);

Expand All @@ -45,7 +58,6 @@ export async function pickFuncProcess(context: IActionContext, debugConfig: vsco
}

await waitForPrevFuncTaskToStop(result.workspace);

const projectFiles = await getProjFiles(context, ProjectLanguage.CSharp, result.workspace.uri.fsPath);
const isBundleProject: boolean = projectFiles.length > 0 ? false : true;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
} from '../../../utils/codeless/common';
import {
addConnectionData,
containsApiHubConnectionReference,
getConnectionsAndSettingsToUpdate,
getConnectionsFromFile,
getLogicAppProjectRoot,
Expand Down Expand Up @@ -255,20 +254,17 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase {
workflow.definition = definitionToSave;

if (connectionReferences) {
const projectPath = await getLogicAppProjectRoot(this.context, filePath);
const connectionsAndSettingsToUpdate = await getConnectionsAndSettingsToUpdate(
this.context,
filePath,
projectPath,
connectionReferences,
azureTenantId,
workflowBaseManagementUri,
parametersFromDefinition
);

await saveConnectionReferences(this.context, filePath, connectionsAndSettingsToUpdate);

if (containsApiHubConnectionReference(connectionReferences)) {
window.showInformationMessage(localize('keyValidity', 'The connection will be valid for 7 days only.'), 'OK');
}
await saveConnectionReferences(this.context, projectPath, connectionsAndSettingsToUpdate);
}

if (parametersFromDefinition) {
Expand Down
55 changes: 55 additions & 0 deletions apps/vs-code-designer/src/app/utils/appSettings/connectionKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { isEmptyString } from '@microsoft/logic-apps-shared';
import { localize } from '../../../localize';
import { tryGetLogicAppProjectRoot } from '../verifyIsProject';
import { getWorkspaceFolder } from '../workspace';
import { getAzureConnectorDetailsForLocalProject } from '../codeless/common';
import { getParametersJson } from '../codeless/parameter';
import type { IActionContext } from '@microsoft/vscode-azext-utils';
import { workspace } from 'vscode';
import { ext } from '../../../extensionVariables';
import type { AzureConnectorDetails, ConnectionsData } from '@microsoft/vscode-extension-logic-apps';
import { getConnectionsAndSettingsToUpdate, getConnectionsJson, saveConnectionReferences } from '../codeless/connection';

export async function verifyLocalConnectionKeys(context: IActionContext): Promise<void> {
if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) {
const workspaceFolder = await getWorkspaceFolder(context);
const projectPath = await tryGetLogicAppProjectRoot(context, workspaceFolder);
let azureDetails: AzureConnectorDetails;

if (projectPath) {
azureDetails = await getAzureConnectorDetailsForLocalProject(context, projectPath);
try {
const connectionsJson = await getConnectionsJson(projectPath);
if (isEmptyString(connectionsJson)) {
ext.outputChannel.appendLog(localize('noConnectionKeysFound', 'No connection keys found to verify'));
return;
}
const connectionsData: ConnectionsData = JSON.parse(connectionsJson);
const parametersData = getParametersJson(projectPath);
const managedApiConnectionReferences = connectionsData.managedApiConnections;

if (connectionsData.managedApiConnections && !(Object.keys(managedApiConnectionReferences).length === 0)) {
const connectionsAndSettingsToUpdate = await getConnectionsAndSettingsToUpdate(
context,
projectPath,
managedApiConnectionReferences,
azureDetails.tenantId,
azureDetails.workflowManagementBaseUrl,
parametersData
);

await saveConnectionReferences(this.context, projectPath, connectionsAndSettingsToUpdate);
}
} catch (error) {
const errorMessage = localize(
'errorVerifyingConnectionKeys',
'Error while verifying existing managed api connections: {0}',
error.message ?? error
);
ext.outputChannel.appendLog(errorMessage);
context.telemetry.properties.error = errorMessage;
throw new Error(errorMessage);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { isKeyExpired } from '../connection';
import { JwtTokenHelper } from '@microsoft/vscode-extension-logic-apps';
import { describe, it, expect } from 'vitest';

describe('isKeyExpired', () => {
it('should expose the isKeyExpired function'),
() => {
expect(isKeyExpired).toBeDefined();
};
});

describe('isKeyExpired with JWTs', () => {
const jwtTokenHelper: JwtTokenHelper = JwtTokenHelper.createInstance();
// expires June 17 2024 17:56:44
const testConnectionKey =
'eyJhbGciOiJSzI1NiIsImtpZCI6IjU5NUZDMDdGQTI0RTEyQjdGRUIyMDU2M0FGNkJEMDQ5REU1ODdBMkQiLCJ4NXQiOiJXVl9BZjZKT0VyZi1zZ1ZqcjJ2UVNkNVllaTAiLCJ0eXAiOiJKV1QifQ.eyJ0cyI6IjcyZjk4OGJmLTg2ZjEtNDFhZi05MWFiLTJkN2NkMDExZGI0NyIsImNzIjoibG9naWMtYXBpcy1ub3J0aGNlbnRyYWx1cy9hcm0vYzU3MDBjZjlmYWNmNDMyNzlmODBmN2E5MTE4MDZlMjgiLCJ2IjoiLTkyMjMzNzIwMzY4NTQ3NzU4MDgiLCJlbnZpZCI6IjlkNTFkMWZmYzlmNzc1NzIiLCJhdWQiOiJodHRwczovLzlkNTFkMWZmYzlmNzc1NzIuMDAuY29tbW9uLmxvZ2ljLW5vcnRoY2VudHJhbHVzLmF6dXJlLWFwaWh1Yi5uZXQvYXBpbS9hcm0vYzU3MDBjZjlmYWNmNDMyNzlmODBmN2E5MTE4MDZlMjgiLCJydW50aW1ldXJsIjoiaHR0cHM6Ly85ZDUxZDFmZmM5Zjc3NTcyLjAwLmNvbW1vbi5sb2dpYy1ub3J0aGNlbnRyYWx1cy5henVyZS1hcGlodWIubmV0L2FwaW0vYXJtL2M1NzAwY2Y5ZmFjZjQzMjc5ZjgwZjdhOTExODA2ZTI4IiwibWFuYWdlbWVudCI6Imh0dHBzOi8vbWFuYWdlbWVudC5sb2dpYy1ub3J0aGNlbnRyYWx1cy5henVyZS1hcGlodWIubmV0LyIsIm5iZiI6MTcxODA0MjIwNCwiZXhwIjoxNzE4NjQ3MDA0LCJpYXQiOjE3MTgwNDIyMDQsImlzcyI6Imh0dHBzOi8vbG9naWMtYXBpcy1ub3J0aGNlbnRyYWx1cy5henVyZS1hcGltLm5ldC8ifQ.LTOKYJfBs2SNVwnOvk2HpHecW2oU_rDDwCpiwrr3l9oLfeTsQIM2yZW7XJav35YsbSOGgW2p9cP--u6skFuEEWwEM2oKulh-5PhJ3V_5Bh08w5UeulgrnD7bThkxvo92U5VhOFEg_-v6vpuwDWFAI2KKdiAed1LzpuTZELvYL-h1ijjO5Xnvss5iFHRJ8BEISGIlKTZmKJsvVCTYocPOj2KA8vEzkqI9L2nCUBrsKraNehND-b6MOz5ZiGJ4bd6zQbZRv904CPrwjGWa7fE0GPgr1HsIg-TjfoDDcM7G3S5VwPbYtzOlXVQCh7-PHtH4MuuhXv7fylRpVSVHnnptAQ';

it('should return false with a JWT expiry later than the test date', () => {
const testDate: number = Date.UTC(2024, 4); // May 1 2024 00:00:00
expect(isKeyExpired(jwtTokenHelper, testDate, testConnectionKey, 0)).toBeFalsy();
});

it('should return true with a JWT expiry earlier than the test date', () => {
const testDate: number = Date.UTC(2024, 6); // July 1 2024 00:00:00
expect(isKeyExpired(jwtTokenHelper, testDate, testConnectionKey, 0)).toBeTruthy();
});

it('should return true with the test date within 3 hours before JWT expiry', () => {
const testDate: number = Date.UTC(2024, 5, 17, 15); // June 17 2024 15:00:00
expect(isKeyExpired(jwtTokenHelper, testDate, testConnectionKey, 3)).toBeTruthy();
});

it('should return false with the test date on the same day as JWT expiry but outside buffer', () => {
const testDate: number = Date.UTC(2024, 5, 17, 2); // June 17 2024 2:00:00
expect(isKeyExpired(jwtTokenHelper, testDate, testConnectionKey, 3)).toBeFalsy();
});
});
57 changes: 47 additions & 10 deletions apps/vs-code-designer/src/app/utils/codeless/connection.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { azurePublicBaseUrl, connectionsFileName } from '../../../constants';
import { azurePublicBaseUrl, connectionsFileName, localSettingsFileName } from '../../../constants';
import { localize } from '../../../localize';
import { isCSharpProject } from '../../commands/initProjectForVSCode/detectProjectLanguage';
import { addOrUpdateLocalAppSettings } from '../appSettings/localSettings';
import { addOrUpdateLocalAppSettings, getLocalSettingsJson } from '../appSettings/localSettings';
import { writeFormattedJson } from '../fs';
import { sendAzureRequest } from '../requestUtils';
import { tryGetLogicAppProjectRoot } from '../verifyIsProject';
import { getContainingWorkspace } from '../workspace';
import { getWorkflowParameters } from './common';
import { getAuthorizationToken } from './getAuthorizationToken';
import { getParametersJson, saveWorkflowParameterRecords } from './parameter';
import * as parameterizer from './parameterizer';
import { addNewFileInCSharpProject } from './updateBuildFile';
import { HTTP_METHODS, isString } from '@microsoft/logic-apps-shared';
import type { ParsedSite } from '@microsoft/vscode-azext-azureappservice';
import { nonNullValue } from '@microsoft/vscode-azext-utils';
import type { IActionContext } from '@microsoft/vscode-azext-utils';
import type {
ILocalSettingsJson,
ServiceProviderConnectionModel,
ConnectionAndSettings,
ConnectionReferenceModel,
Expand All @@ -24,10 +24,12 @@ import type {
ConnectionAndAppSetting,
Parameter,
} from '@microsoft/vscode-extension-logic-apps';
import { JwtTokenHelper, JwtTokenConstants, resolveConnectionsReferences } from '@microsoft/vscode-extension-logic-apps';
import axios from 'axios';
import * as fse from 'fs-extra';
import * as path from 'path';
import * as vscode from 'vscode';
import { parameterizeConnection } from './parameterizer';

export async function getConnectionsFromFile(context: IActionContext, workflowFilePath: string): Promise<string> {
const projectRoot: string = await getLogicAppProjectRoot(context, workflowFilePath);
Expand Down Expand Up @@ -113,7 +115,7 @@ async function addConnectionDataInJson(
return;
}

parameterizer.parameterizeConnection(connectionData, connectionKey, parametersData, settings);
parameterizeConnection(connectionData, connectionKey, parametersData, settings);

pathToSetConnectionsData[connectionKey] = connectionData;
await writeFormattedJson(connectionsFilePath, connectionsJson);
Expand All @@ -123,6 +125,25 @@ async function addConnectionDataInJson(
}
}

export function isKeyExpired(jwtTokenHelper: JwtTokenHelper, date: number, connectionKey: string, bufferInHours: number): boolean {
const payload: Record<string, any> = jwtTokenHelper.extractJwtTokenPayload(connectionKey);
const secondsSinceEpoch = date / 1000;
const buffer = bufferInHours * 3600; // convert to seconds
const expiry = payload[JwtTokenConstants.expiry];

return expiry - buffer <= secondsSinceEpoch;
}

function formatSetting(setting: string): string {
if (setting.endsWith('/')) {
setting = setting.substring(0, setting.length - 1);
}
if (setting.startsWith('/')) {
setting = setting.substring(1);
}
return setting;
}

async function getConnectionReference(
referenceKey: string,
reference: any,
Expand All @@ -139,7 +160,7 @@ async function getConnectionReference(

return axios
.post(
`${workflowBaseManagementUri}/${connectionId}/listConnectionKeys?api-version=2018-07-01-preview`,
`${formatSetting(workflowBaseManagementUri)}/${formatSetting(connectionId)}/listConnectionKeys?api-version=2018-07-01-preview`,
{ validityTimeSpan: '7' },
{ headers: { authorization: accessToken } }
)
Expand All @@ -159,7 +180,7 @@ async function getConnectionReference(
connectionProperties,
};

parameterizer.parameterizeConnection(connectionReference, referenceKey, parametersToAdd, settingsToAdd);
parameterizeConnection(connectionReference, referenceKey, parametersToAdd, settingsToAdd);

return connectionReference;
})
Expand All @@ -170,18 +191,20 @@ async function getConnectionReference(

export async function getConnectionsAndSettingsToUpdate(
context: IActionContext,
workflowFilePath: string,
projectPath: string,
connectionReferences: any,
azureTenantId: string,
workflowBaseManagementUri: string,
parametersFromDefinition: any
): Promise<ConnectionAndSettings> {
const projectPath = await getLogicAppProjectRoot(context, workflowFilePath);
const connectionsDataString = projectPath ? await getConnectionsJson(projectPath) : '';
const connectionsData = connectionsDataString === '' ? {} : JSON.parse(connectionsDataString);
const localSettingsPath: string = path.join(projectPath, localSettingsFileName);
const localSettings: ILocalSettingsJson = await getLocalSettingsJson(context, localSettingsPath);

const referencesToAdd = connectionsData.managedApiConnections || {};
const settingsToAdd: Record<string, string> = {};
const jwtTokenHelper: JwtTokenHelper = JwtTokenHelper.createInstance();
let accessToken: string | undefined;

for (const referenceKey of Object.keys(connectionReferences)) {
Expand All @@ -197,6 +220,21 @@ export async function getConnectionsAndSettingsToUpdate(
settingsToAdd,
parametersFromDefinition
);
} else if (
localSettings.Values[`${referenceKey}-connectionKey`] &&
isKeyExpired(jwtTokenHelper, Date.now(), localSettings.Values[`${referenceKey}-connectionKey`], 3)
) {
const resolvedConnectionReference = resolveConnectionsReferences(JSON.stringify(reference), undefined, localSettings.Values);

accessToken = accessToken ? accessToken : await getAuthorizationToken(/* credentials */ undefined, azureTenantId);
referencesToAdd[referenceKey] = await getConnectionReference(
referenceKey,
resolvedConnectionReference,
accessToken,
workflowBaseManagementUri,
settingsToAdd,
parametersFromDefinition
);
}
}

Expand All @@ -210,10 +248,9 @@ export async function getConnectionsAndSettingsToUpdate(

export async function saveConnectionReferences(
context: IActionContext,
workflowFilePath: string,
projectPath: string,
connectionAndSettingsToUpdate: ConnectionAndSettings
): Promise<void> {
const projectPath = await getLogicAppProjectRoot(context, workflowFilePath);
const { connections, settings } = connectionAndSettingsToUpdate;
const connectionsFilePath = path.join(projectPath, connectionsFileName);
const connectionsFileExists = fse.pathExistsSync(connectionsFilePath);
Expand Down
1 change: 1 addition & 0 deletions apps/vs-code-designer/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export const dotNetBinaryPathSettingKey = 'dotnetBinaryPath';
export const nodeJsBinaryPathSettingKey = 'nodeJsBinaryPath';
export const funcCoreToolsBinaryPathSettingKey = 'funcCoreToolsBinaryPath';
export const dependencyTimeoutSettingKey = 'dependencyTimeout';
export const verifyConnectionKeysSetting = 'verifyConnectionKeys';

// host.json
export const extensionBundleId = 'Microsoft.Azure.Functions.ExtensionBundle.Workflows';
Expand Down
3 changes: 2 additions & 1 deletion apps/vs-code-designer/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { extensionCommand, logicAppFilter } from './constants';
import { ext } from './extensionVariables';
import { startOnboarding } from './onboarding';
import { registerAppServiceExtensionVariables } from '@microsoft/vscode-azext-azureappservice';
import { verifyLocalConnectionKeys } from './app/utils/appSettings/connectionKeys';
import {
callWithTelemetryAndErrorHandling,
createAzExtOutputChannel,
Expand Down Expand Up @@ -46,7 +47,6 @@ export async function activate(context: vscode.ExtensionContext) {
ext.context = context;

ext.outputChannel = createAzExtOutputChannel('Azure Logic Apps (Standard)', ext.prefix);

registerUIExtensionVariables(ext);
registerAppServiceExtensionVariables(ext);

Expand All @@ -58,6 +58,7 @@ export async function activate(context: vscode.ExtensionContext) {

await downloadExtensionBundle(activateContext);
promptParameterizeConnections(activateContext);
verifyLocalConnectionKeys(activateContext);
await startOnboarding(activateContext);

ext.extensionVersion = getExtensionVersion();
Expand Down
17 changes: 16 additions & 1 deletion apps/vs-code-designer/test-setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
import { afterEach, vi } from 'vitest';

// https://testing-library.com/docs/react-testing-library/api#cleanup
afterEach(() => cleanup());

vi.mock('@microsoft/vscode-azext-azureutils', () => ({
// mock implementation or empty object
}));

vi.mock('@microsoft/vscode-azext-utils', () => {
return {
AzureWizardExecuteStep: vi.fn().mockImplementation(() => {
return {};
}),
AzureWizardPromptStep: vi.fn().mockImplementation(() => {
return {};
}),
};
});
16 changes: 5 additions & 11 deletions apps/vs-code-designer/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
import { defineProject } from 'vitest/config';
import packageJson from './package.json';
import path from 'path';

export default defineProject({
plugins: [],
resolve: {
alias: [
{
find: 'vscode',
replacement: `${__dirname}/node_modules/@types/vscode/index.d`,
},
{
find: '@microsoft/vscode-azext-utils',
replacement: `${__dirname}/node_modules/@types/vscode/index.d`,
},
],
alias: {
vscode: path.resolve(path.join(__dirname, 'node_modules', '@types', 'vscode', 'index.d.ts')),
},
},
test: {
name: packageJson.name,
dir: './src',
watch: false,
environment: 'jsdom',
environment: 'node',
setupFiles: ['test-setup.ts'],
coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'], reporter: ['html', 'cobertura'] },
restoreMocks: true,
Expand Down
Loading

0 comments on commit c31d440

Please sign in to comment.