diff --git a/packages/fx-core/resource/package.nls.json b/packages/fx-core/resource/package.nls.json index bf691be2deb..f3953d05f02 100644 --- a/packages/fx-core/resource/package.nls.json +++ b/packages/fx-core/resource/package.nls.json @@ -561,7 +561,8 @@ "core.summary.actionSucceeded": "%s was executed successfully.", "core.summary.createdEnvFile": "Environment file was created at", "core.copilot.addAPI.success": "%s have(has) been successfully added to %s", - "core.copilot.addAPI.InjectAPIKeyActionFailed": "Inject API key action to teamsapp.yaml file failed, please make sure that file contains teamsApp/create action in provision section.", + "core.copilot.addAPI.InjectAPIKeyActionFailed": "Inject API key action to teamsapp.yaml file unsuccessful, make sure the file contains teamsApp/create action in provision section.", + "core.copilot.addAPI.InjectOAuthActionFailed": "Inject OAuth action to teamsapp.yaml file unsuccessful, make sure the file contains teamsApp/create action in provision section.", "ui.select.LoadingOptionsPlaceholder": "Loading options ...", "ui.select.LoadingDefaultPlaceholder": "Loading default value ...", "error.aad.manifest.NameIsMissing": "name is missing\n", diff --git a/packages/fx-core/src/component/configManager/actionInjector.ts b/packages/fx-core/src/component/configManager/actionInjector.ts new file mode 100644 index 00000000000..c8d1d1fee24 --- /dev/null +++ b/packages/fx-core/src/component/configManager/actionInjector.ts @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Utils } from "@microsoft/m365-spec-parser"; +import fs from "fs-extra"; +import { parseDocument } from "yaml"; +import { InjectAPIKeyActionFailedError, InjectOAuthActionFailedError } from "../../error/common"; + +export class ActionInjector { + static hasActionWithName(provisionNode: any, action: string, name: string): any { + const hasAuthAction = provisionNode.items.some( + (item: any) => item.get("uses") === action && item.get("with")?.get("name") === name + ); + return hasAuthAction; + } + + static getTeamsAppIdEnvName(provisionNode: any): string | undefined { + for (const item of provisionNode.items) { + if (item.get("uses") === "teamsApp/create") { + return item.get("writeToEnvironmentFile")?.get("teamsAppId") as string; + } + } + + return undefined; + } + + static generateAuthAction( + actionName: string, + authName: string, + teamsAppIdEnvName: string, + specRelativePath: string, + envName: string, + flow?: string + ): any { + const result: any = { + uses: actionName, + with: { + name: `${authName}`, + appId: `\${{${teamsAppIdEnvName}}}`, + apiSpecPath: specRelativePath, + }, + }; + + if (flow) { + result.with.flow = flow; + result.writeToEnvironmentFile = { + configurationId: envName, + }; + } else { + result.writeToEnvironmentFile = { + registrationId: envName, + }; + } + + return result; + } + + static async injectCreateOAuthAction( + ymlPath: string, + authName: string, + specRelativePath: string + ): Promise { + const ymlContent = await fs.readFile(ymlPath, "utf-8"); + const actionName = "oauth/register"; + + const document = parseDocument(ymlContent); + const provisionNode = document.get("provision") as any; + if (provisionNode) { + const hasOAuthAction = ActionInjector.hasActionWithName(provisionNode, actionName, authName); + if (!hasOAuthAction) { + provisionNode.items = provisionNode.items.filter( + (item: any) => item.get("uses") !== actionName && item.get("uses") !== "apiKey/register" + ); + const teamsAppIdEnvName = ActionInjector.getTeamsAppIdEnvName(provisionNode); + if (teamsAppIdEnvName) { + const index: number = provisionNode.items.findIndex( + (item: any) => item.get("uses") === "teamsApp/create" + ); + const envName = Utils.getSafeRegistrationIdEnvName(`${authName}_CONFIGURATION_ID`); + const flow = "authorizationCode"; + const action = ActionInjector.generateAuthAction( + actionName, + authName, + teamsAppIdEnvName, + specRelativePath, + envName, + flow + ); + provisionNode.items.splice(index + 1, 0, action); + } else { + throw new InjectOAuthActionFailedError(); + } + + await fs.writeFile(ymlPath, document.toString(), "utf8"); + } + } else { + throw new InjectOAuthActionFailedError(); + } + } + + static async injectCreateAPIKeyAction( + ymlPath: string, + authName: string, + specRelativePath: string + ): Promise { + const ymlContent = await fs.readFile(ymlPath, "utf-8"); + const actionName = "apiKey/register"; + + const document = parseDocument(ymlContent); + const provisionNode = document.get("provision") as any; + + if (provisionNode) { + const hasApiKeyAction = ActionInjector.hasActionWithName(provisionNode, actionName, authName); + + if (!hasApiKeyAction) { + provisionNode.items = provisionNode.items.filter( + (item: any) => item.get("uses") !== actionName && item.get("uses") !== "oauth/register" + ); + const teamsAppIdEnvName = ActionInjector.getTeamsAppIdEnvName(provisionNode); + if (teamsAppIdEnvName) { + const index: number = provisionNode.items.findIndex( + (item: any) => item.get("uses") === "teamsApp/create" + ); + const envName = Utils.getSafeRegistrationIdEnvName(`${authName}_REGISTRATION_ID`); + const action = ActionInjector.generateAuthAction( + actionName, + authName, + teamsAppIdEnvName, + specRelativePath, + envName + ); + provisionNode.items.splice(index + 1, 0, action); + } else { + throw new InjectAPIKeyActionFailedError(); + } + + await fs.writeFile(ymlPath, document.toString(), "utf8"); + } + } else { + throw new InjectAPIKeyActionFailedError(); + } + } +} diff --git a/packages/fx-core/src/core/FxCore.ts b/packages/fx-core/src/core/FxCore.ts index 3654ccebe06..9bc4eeb60c9 100644 --- a/packages/fx-core/src/core/FxCore.ts +++ b/packages/fx-core/src/core/FxCore.ts @@ -30,6 +30,7 @@ import { err, ok, } from "@microsoft/teamsfx-api"; +import { OpenAPIV3 } from "openapi-types"; import { DotenvParseOutput } from "dotenv"; import fs from "fs-extra"; import * as jsonschema from "jsonschema"; @@ -108,7 +109,6 @@ import { pathUtils } from "../component/utils/pathUtils"; import { settingsUtil } from "../component/utils/settingsUtil"; import { FileNotFoundError, - InjectAPIKeyActionFailedError, InputValidationError, InvalidProjectError, MissingRequiredInputError, @@ -149,6 +149,7 @@ import { CoreHookContext, PreProvisionResForVS, VersionCheckRes } from "./types" import { AppStudioResultFactory } from "../component/driver/teamsApp/results"; import { AppStudioError } from "../component/driver/teamsApp/errors"; import { copilotGptManifestUtils } from "../component/driver/teamsApp/utils/CopilotGptManifestUtils"; +import { ActionInjector } from "../component/configManager/actionInjector"; export type CoreCallbackFunc = (name: string, err?: FxError, data?: any) => void | Promise; @@ -1240,61 +1241,6 @@ export class FxCore { return await coordinator.publishInDeveloperPortal(context, inputs as InputsWithProjectPath); } - async injectCreateAPIKeyAction( - ymlPath: string, - authName: string, - specRelativePath: string - ): Promise { - const ymlContent = await fs.readFile(ymlPath, "utf-8"); - - const document = parseDocument(ymlContent); - const provisionNode = document.get("provision") as any; - - if (provisionNode) { - const hasApiKeyAction = provisionNode.items.some( - (item: any) => - item.get("uses") === "apiKey/register" && item.get("with")?.get("name") === authName - ); - - if (!hasApiKeyAction) { - provisionNode.items = provisionNode.items.filter( - (item: any) => item.get("uses") !== "apiKey/register" - ); - let added = false; - for (let i = 0; i < provisionNode.items.length; i++) { - const item = provisionNode.items[i]; - if (item.get("uses") === "teamsApp/create") { - const teamsAppId = item.get("writeToEnvironmentFile")?.get("teamsAppId") as string; - if (teamsAppId) { - const envName = Utils.getSafeRegistrationIdEnvName(`${authName}_REGISTRATION_ID`); - provisionNode.items.splice(i + 1, 0, { - uses: "apiKey/register", - with: { - name: `${authName}`, - appId: `\${{${teamsAppId}}}`, - apiSpecPath: specRelativePath, - }, - writeToEnvironmentFile: { - registrationId: envName, - }, - }); - added = true; - break; - } - } - } - - if (!added) { - throw new InjectAPIKeyActionFailedError(); - } - - await fs.writeFile(ymlPath, document.toString(), "utf8"); - } - } else { - throw new InjectAPIKeyActionFailedError(); - } - } - @hooks([ ErrorContextMW({ component: "FxCore", stage: "copilotPluginAddAPI" }), ErrorHandlerMW, @@ -1371,43 +1317,54 @@ export class FxCore { ); try { - // TODO: type b will support auth - if (!isPlugin) { - const authNames: Set = new Set(); - const serverUrls: Set = new Set(); - for (const api of operations) { - const operation = apiResultList.find((op) => op.api === api); - if ( - operation && - operation.auth && - (Utils.isBearerTokenAuth(operation.auth.authScheme) || - Utils.isOAuthWithAuthCodeFlow(operation.auth.authScheme)) - ) { - authNames.add(operation.auth.name); - serverUrls.add(operation.server); - } + const authNames: Set = new Set(); + const serverUrls: Set = new Set(); + let authScheme: OpenAPIV3.SecuritySchemeObject | undefined = undefined; + for (const api of operations) { + const operation = apiResultList.find((op) => op.api === api); + if ( + operation && + operation.auth && + (Utils.isBearerTokenAuth(operation.auth.authScheme) || + Utils.isOAuthWithAuthCodeFlow(operation.auth.authScheme)) + ) { + authNames.add(operation.auth.name); + serverUrls.add(operation.server); + authScheme = operation.auth.authScheme; } + } - if (authNames.size > 1) { - throw new MultipleAuthError(authNames); - } + if (authNames.size > 1) { + throw new MultipleAuthError(authNames); + } - if (serverUrls.size > 1) { - throw new MultipleServerError(serverUrls); - } + if (serverUrls.size > 1) { + throw new MultipleServerError(serverUrls); + } + + if (authNames.size === 1 && authScheme) { + const ymlPath = path.join(inputs.projectPath!, MetadataV3.configFile); + const localYamlPath = path.join(inputs.projectPath!, MetadataV3.localConfigFile); + const authName = [...authNames][0]; - if (authNames.size === 1) { - const ymlPath = path.join(inputs.projectPath!, MetadataV3.configFile); - const localYamlPath = path.join(inputs.projectPath!, MetadataV3.localConfigFile); - const authName = [...authNames][0]; + const relativeSpecPath = + "./" + path.relative(inputs.projectPath!, outputApiSpecPath).replace(/\\/g, "/"); - const relativeSpecPath = - "./" + path.relative(inputs.projectPath!, outputApiSpecPath).replace(/\\/g, "/"); + if (Utils.isBearerTokenAuth(authScheme)) { + await ActionInjector.injectCreateAPIKeyAction(ymlPath, authName, relativeSpecPath); - await this.injectCreateAPIKeyAction(ymlPath, authName, relativeSpecPath); + if (await fs.pathExists(localYamlPath)) { + await ActionInjector.injectCreateAPIKeyAction( + localYamlPath, + authName, + relativeSpecPath + ); + } + } else if (Utils.isOAuthWithAuthCodeFlow(authScheme)) { + await ActionInjector.injectCreateOAuthAction(ymlPath, authName, relativeSpecPath); if (await fs.pathExists(localYamlPath)) { - await this.injectCreateAPIKeyAction(localYamlPath, authName, relativeSpecPath); + await ActionInjector.injectCreateOAuthAction(localYamlPath, authName, relativeSpecPath); } } } diff --git a/packages/fx-core/src/error/common.ts b/packages/fx-core/src/error/common.ts index c7314e90c18..2b3e918178a 100644 --- a/packages/fx-core/src/error/common.ts +++ b/packages/fx-core/src/error/common.ts @@ -121,6 +121,17 @@ export class InjectAPIKeyActionFailedError extends UserError { } } +export class InjectOAuthActionFailedError extends UserError { + constructor() { + super({ + message: getDefaultString("core.copilot.addAPI.InjectOAuthActionFailed"), + displayMessage: getLocalizedString("core.copilot.addAPI.InjectOAuthActionFailed"), + source: "coordinator", + categories: [ErrorCategory.Internal], + }); + } +} + export class JSONSyntaxError extends UserError { constructor(filePathOrContent: string, error: any, source?: string) { super({ diff --git a/packages/fx-core/tests/core/FxCore.test.ts b/packages/fx-core/tests/core/FxCore.test.ts index d2452a3f8d0..3af56b76d6a 100644 --- a/packages/fx-core/tests/core/FxCore.test.ts +++ b/packages/fx-core/tests/core/FxCore.test.ts @@ -2088,6 +2088,101 @@ describe("copilotPlugin", async () => { } }); + it("add API - no provision section in teamsapp yaml file - OAuth", async () => { + const appName = await mockV3Project(); + mockedEnvRestore = mockedEnv({ + TEAMSFX_CLI_DOTNET: "false", + }); + const inputs: Inputs = { + platform: Platform.VSCode, + [QuestionNames.Folder]: os.tmpdir(), + [QuestionNames.ApiSpecLocation]: "test.json", + [QuestionNames.ApiOperation]: ["GET /user/{userId}", "GET /store/order"], + [QuestionNames.ManifestPath]: "manifest.json", + projectPath: path.join(os.tmpdir(), appName), + }; + const manifest = new TeamsAppManifest(); + manifest.composeExtensions = [ + { + composeExtensionType: "apiBased", + apiSpecificationFile: "apiSpecificationFiles/openapi.json", + commands: [], + }, + ]; + + const listResult: ListAPIResult = { + APIs: [ + { + operationId: "getUserById", + server: "https://server1", + api: "GET /user/{userId}", + auth: { + name: "oauthAuth", + authScheme: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "mockedAuthorizationUrl", + tokenUrl: "mockedTokenUrl", + scopes: { + mockedScope: "description for mocked scope", + }, + }, + }, + }, + }, + isValid: true, + reason: [], + }, + { + operationId: "getStoreOrder", + server: "https://server1", + api: "GET /store/order", + auth: { + name: "oauthAuth", + authScheme: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "mockedAuthorizationUrl", + tokenUrl: "mockedTokenUrl", + scopes: { + mockedScope: "description for mocked scope", + }, + }, + }, + }, + }, + isValid: true, + reason: [], + }, + ], + validAPICount: 2, + allAPICount: 2, + }; + + const core = new FxCore(tools); + sinon.stub(SpecParser.prototype, "generate").resolves({ + warnings: [], + allSuccess: true, + }); + sinon.stub(SpecParser.prototype, "list").resolves(listResult); + sinon.stub(manifestUtils, "_readAppManifest").resolves(ok(manifest)); + sinon.stub(validationUtils, "validateInputs").resolves(undefined); + const teamsappObject = { + version: "1.0.0", + }; + const yamlString = jsyaml.dump(teamsappObject); + sinon.stub(fs, "pathExists").resolves(true); + sinon.stub(fs, "readFile").resolves(yamlString as any); + sinon.stub(tools.ui, "showMessage").resolves(ok("Add")); + const result = await core.copilotPluginAddAPI(inputs); + assert.isTrue(result.isErr()); + if (result.isErr()) { + assert.equal((result.error as FxError).name, "InjectOAuthActionFailedError"); + } + }); + it("add API - no provision section in teamsapp yaml file", async () => { const appName = await mockV3Project(); mockedEnvRestore = mockedEnv({ @@ -2352,6 +2447,119 @@ describe("copilotPlugin", async () => { } }); + it("add API - no teams app id in teamsapp yaml file - OAuth", async () => { + const appName = await mockV3Project(); + mockedEnvRestore = mockedEnv({ + TEAMSFX_CLI_DOTNET: "false", + }); + const inputs: Inputs = { + platform: Platform.VSCode, + [QuestionNames.Folder]: os.tmpdir(), + [QuestionNames.ApiSpecLocation]: "test.json", + [QuestionNames.ApiOperation]: ["GET /user/{userId}", "GET /store/order"], + [QuestionNames.ManifestPath]: "manifest.json", + projectPath: path.join(os.tmpdir(), appName), + }; + const manifest = new TeamsAppManifest(); + manifest.composeExtensions = [ + { + composeExtensionType: "apiBased", + apiSpecificationFile: "apiSpecificationFiles/openapi.json", + commands: [], + }, + ]; + + const listResult: ListAPIResult = { + APIs: [ + { + operationId: "getUserById", + server: "https://server1", + api: "GET /user/{userId}", + auth: { + name: "oauthAuth", + authScheme: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "mockedAuthorizationUrl", + tokenUrl: "mockedTokenUrl", + scopes: { + mockedScope: "description for mocked scope", + }, + }, + }, + }, + }, + isValid: true, + reason: [], + }, + { + operationId: "getStoreOrder", + server: "https://server1", + api: "GET /store/order", + auth: { + name: "oauthAuth", + authScheme: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "mockedAuthorizationUrl", + tokenUrl: "mockedTokenUrl", + scopes: { + mockedScope: "description for mocked scope", + }, + }, + }, + }, + }, + isValid: true, + reason: [], + }, + ], + validAPICount: 2, + allAPICount: 2, + }; + + const core = new FxCore(tools); + sinon.stub(SpecParser.prototype, "generate").resolves({ + warnings: [], + allSuccess: true, + }); + sinon.stub(SpecParser.prototype, "list").resolves(listResult); + sinon.stub(manifestUtils, "_readAppManifest").resolves(ok(manifest)); + sinon.stub(validationUtils, "validateInputs").resolves(undefined); + sinon.stub(tools.ui, "showMessage").resolves(ok("Add")); + const teamsappObject = { + provision: [ + { + uses: "teamsApp/create", + with: { + name: "dfefeef-${{TEAMSFX_ENV}}", + }, + writeToEnvironmentFile: { + otherEnv: "OtherEnv", + }, + }, + { + uses: "teamsApp/zipAppPackage", + with: { + manifestPath: "./appPackage/manifest.json", + outputZipPath: "./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip", + outputJsonPath: "./appPackage/build/manifest.${{TEAMSFX_ENV}}.json", + }, + }, + ], + }; + const yamlString = jsyaml.dump(teamsappObject); + sinon.stub(fs, "pathExists").resolves(true); + sinon.stub(fs, "readFile").resolves(yamlString as any); + const result = await core.copilotPluginAddAPI(inputs); + assert.isTrue(result.isErr()); + if (result.isErr()) { + assert.equal((result.error as FxError).name, "InjectOAuthActionFailedError"); + } + }); + it("add API - should inject api key action to teamsapp yaml file", async () => { const appName = await mockV3Project(); mockedEnvRestore = mockedEnv({ @@ -3169,6 +3377,158 @@ describe("copilotPlugin", async () => { assert.isTrue(writeYamlObjectTriggeredTimes === 2); }); + it("add API - should inject oauth action to teamsapp yaml file with local teamsapp file", async () => { + const appName = await mockV3Project(); + mockedEnvRestore = mockedEnv({ + TEAMSFX_CLI_DOTNET: "false", + }); + const inputs: Inputs = { + platform: Platform.VSCode, + [QuestionNames.Folder]: os.tmpdir(), + [QuestionNames.ApiSpecLocation]: "test.json", + [QuestionNames.ApiOperation]: ["GET /user/{userId}", "GET /store/order"], + [QuestionNames.ManifestPath]: path.join(os.tmpdir(), appName, "appPackage/manifest.json"), + projectPath: path.join(os.tmpdir(), appName), + }; + const manifest = new TeamsAppManifest(); + manifest.composeExtensions = [ + { + composeExtensionType: "apiBased", + apiSpecificationFile: "apiSpecificationFiles/openapi.json", + commands: [], + }, + ]; + + const listResult: ListAPIResult = { + APIs: [ + { + operationId: "getUserById", + server: "https://server1", + api: "GET /user/{userId}", + auth: { + name: "oauthAuth", + authScheme: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "mockedAuthorizationUrl", + tokenUrl: "mockedTokenUrl", + scopes: { + mockedScope: "description for mocked scope", + }, + }, + }, + }, + }, + isValid: true, + reason: [], + }, + { + operationId: "getStoreOrder", + server: "https://server1", + api: "GET /store/order", + auth: { + name: "oauthAuth", + authScheme: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "mockedAuthorizationUrl", + tokenUrl: "mockedTokenUrl", + scopes: { + mockedScope: "description for mocked scope", + }, + }, + }, + }, + }, + isValid: true, + reason: [], + }, + ], + validAPICount: 2, + allAPICount: 2, + }; + + const core = new FxCore(tools); + sinon.stub(SpecParser.prototype, "generate").resolves({ + warnings: [], + allSuccess: true, + }); + sinon.stub(SpecParser.prototype, "list").resolves(listResult); + sinon.stub(manifestUtils, "_readAppManifest").resolves(ok(manifest)); + sinon.stub(validationUtils, "validateInputs").resolves(undefined); + sinon.stub(tools.ui, "showMessage").resolves(ok("Add")); + const teamsappObject = { + provision: [ + { + uses: "teamsApp/create", + with: { + name: "dfefeef-${{TEAMSFX_ENV}}", + }, + writeToEnvironmentFile: { + teamsAppId: "TEAMS_APP_ID", + }, + }, + { + uses: "teamsApp/zipAppPackage", + with: { + manifestPath: "./appPackage/manifest.json", + outputZipPath: "./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip", + outputJsonPath: "./appPackage/build/manifest.${{TEAMSFX_ENV}}.json", + }, + }, + ], + }; + const yamlString = jsyaml.dump(teamsappObject); + sinon.stub(fs, "pathExists").resolves(true); + sinon.stub(fs, "readFile").resolves(yamlString as any); + + let writeYamlObjectTriggeredTimes = 0; + sinon.stub(fs, "writeFile").callsFake((_, yamlString) => { + writeYamlObjectTriggeredTimes++; + const yamlObject = jsyaml.load(yamlString); + assert.deepEqual(yamlObject, { + provision: [ + { + uses: "teamsApp/create", + with: { + name: "dfefeef-${{TEAMSFX_ENV}}", + }, + writeToEnvironmentFile: { + teamsAppId: "TEAMS_APP_ID", + }, + }, + { + uses: "oauth/register", + with: { + name: "oauthAuth", + flow: "authorizationCode", + appId: "${{TEAMS_APP_ID}}", + apiSpecPath: "./appPackage/apiSpecificationFiles/openapi.json", + }, + writeToEnvironmentFile: { + configurationId: "OAUTHAUTH_CONFIGURATION_ID", + }, + }, + { + uses: "teamsApp/zipAppPackage", + with: { + manifestPath: "./appPackage/manifest.json", + outputZipPath: "./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip", + outputJsonPath: "./appPackage/build/manifest.${{TEAMSFX_ENV}}.json", + }, + }, + ], + }); + }); + + const result = await core.copilotPluginAddAPI(inputs); + + assert.isTrue(result.isOk()); + assert.isTrue(writeYamlObjectTriggeredTimes === 2); + }); + it("add API - should filter unknown api key action", async () => { const appName = await mockV3Project(); mockedEnvRestore = mockedEnv({