From d7f13c7da3c118294a18103f913e342e124f6f51 Mon Sep 17 00:00:00 2001 From: lakhveerk <88564543+lakhveerk@users.noreply.github.com> Date: Thu, 3 Oct 2024 09:24:18 -0700 Subject: [PATCH] CEA Auth changes (#2483) * initial cecAuth changes * name fixes * Adding support or Action Execute Auth * taking off callback from CEC APIs * fixing default value for external-app-auth input * cec name updated to cea * adding documentation for new CEA APIs * changing .then logic to async/await * changing incoming response from hubsdk * moving interfaces to their original place * telemetry fix + testing * change file * e2e test for isSupported * matching string for e2e test * addressig review feedback * adding documenation * using sendAndUnwrap call instead of sendMessageToParentAsync --- .../externalAppAuthenticationForCEA.json | 16 + .../externalAppCardActionsForCEA.json | 3 + .../ExternalAppAuthenticationAPIs.tsx | 2 +- .../ExternalAppAuthenticationForCEAAPIs.tsx | 219 ++++++ apps/teams-test-app/src/pages/TestApp.tsx | 2 + ...-cee144c2-e3b9-40a5-bb3e-05608280d179.json | 7 + packages/teams-js/src/internal/telemetry.ts | 4 + .../src/private/externalAppAuthentication.ts | 47 +- .../externalAppAuthenticationForCEA.ts | 249 +++++++ packages/teams-js/src/private/index.ts | 1 + packages/teams-js/src/public/runtime.ts | 1 + .../externalAppAuthenticationForCEA.spec.ts | 623 ++++++++++++++++++ 12 files changed, 1171 insertions(+), 3 deletions(-) create mode 100644 apps/teams-test-app/e2e-test-data/externalAppAuthenticationForCEA.json create mode 100644 apps/teams-test-app/src/components/privateApis/ExternalAppAuthenticationForCEAAPIs.tsx create mode 100644 change/@microsoft-teams-js-cee144c2-e3b9-40a5-bb3e-05608280d179.json create mode 100644 packages/teams-js/src/private/externalAppAuthenticationForCEA.ts create mode 100644 packages/teams-js/test/private/externalAppAuthenticationForCEA.spec.ts diff --git a/apps/teams-test-app/e2e-test-data/externalAppAuthenticationForCEA.json b/apps/teams-test-app/e2e-test-data/externalAppAuthenticationForCEA.json new file mode 100644 index 0000000000..2c2263358a --- /dev/null +++ b/apps/teams-test-app/e2e-test-data/externalAppAuthenticationForCEA.json @@ -0,0 +1,16 @@ +{ + "name": "ExternalAppAuthenticationForCEA", + "version": ">2.29.0", + "hostSdkVersion": { + "web": ">=4.3.0" + }, + "platform": "Web", + "testCases": [ + { + "title": "checkExternalAppAuthenticationForCEACapability API Call - Success", + "type": "callResponse", + "boxSelector": "#box_checkExternalAppAuthenticationForCEACapability", + "expectedTestAppValue": "External App Authentication For CEA module is supported" + } + ] +} diff --git a/apps/teams-test-app/e2e-test-data/externalAppCardActionsForCEA.json b/apps/teams-test-app/e2e-test-data/externalAppCardActionsForCEA.json index f6c1dfdf46..fb073d97a1 100644 --- a/apps/teams-test-app/e2e-test-data/externalAppCardActionsForCEA.json +++ b/apps/teams-test-app/e2e-test-data/externalAppCardActionsForCEA.json @@ -1,6 +1,9 @@ { "name": "ExternalAppCardActionsForCEA", "version": ">=2.29.0", + "hostSdkVersion": { + "web": ">=4.3.0" + }, "platform": "Web", "testCases": [ { diff --git a/apps/teams-test-app/src/components/privateApis/ExternalAppAuthenticationAPIs.tsx b/apps/teams-test-app/src/components/privateApis/ExternalAppAuthenticationAPIs.tsx index 3c6ea173d0..b1cae2675a 100644 --- a/apps/teams-test-app/src/components/privateApis/ExternalAppAuthenticationAPIs.tsx +++ b/apps/teams-test-app/src/components/privateApis/ExternalAppAuthenticationAPIs.tsx @@ -50,7 +50,7 @@ const AuthenticateAndResendRequest = (): React.ReactElement => defaultInput: JSON.stringify({ appId: 'b7f8c0a0-6c1d-4a9a-9c0a-2c3f1c0a3b0a', authenticateParameters: { - url: 'https://www.example.com', + url: 'https://localhost:4000', width: 100, height: 100, isExternal: true, diff --git a/apps/teams-test-app/src/components/privateApis/ExternalAppAuthenticationForCEAAPIs.tsx b/apps/teams-test-app/src/components/privateApis/ExternalAppAuthenticationForCEAAPIs.tsx new file mode 100644 index 0000000000..138bd38ed4 --- /dev/null +++ b/apps/teams-test-app/src/components/privateApis/ExternalAppAuthenticationForCEAAPIs.tsx @@ -0,0 +1,219 @@ +import { AppId, externalAppAuthentication, externalAppAuthenticationForCEA } from '@microsoft/teams-js'; +import React from 'react'; + +import { ApiWithoutInput } from '../utils/ApiWithoutInput'; +import { ApiWithTextInput } from '../utils/ApiWithTextInput'; +import { ModuleWrapper } from '../utils/ModuleWrapper'; + +const CheckExternalAppAuthenticationForCEACapability = (): React.ReactElement => + ApiWithoutInput({ + name: 'checkExternalAppAuthenticationForCEACapability', + title: 'Check External App Authentication CEA Capability', + onClick: async () => + `External App Authentication For CEA module ${externalAppAuthenticationForCEA.isSupported() ? 'is' : 'is not'} supported`, + }); + +const AuthenticateWithOAuthForCEA = (): React.ReactElement => + ApiWithTextInput<{ + appId: string; + conversationId: string; + authenticateParameters: { + url: string; + width?: number; + height?: number; + isExternal?: boolean; + }; + }>({ + name: 'AuthenticateWithOAuthForCEA', + title: 'Authenticate With OAuth', + onClick: { + validateInput: (input) => { + if (!input.appId) { + throw new Error('appId is required'); + } + if (!input.conversationId) { + throw new Error('conversationId is required'); + } + if (!input.authenticateParameters) { + throw new Error('authenticateParameters is required'); + } + }, + submit: async (input) => { + await externalAppAuthenticationForCEA.authenticateWithOauth(new AppId(input.appId), input.conversationId, { + ...input.authenticateParameters, + url: new URL(input.authenticateParameters.url), + }); + return 'Completed'; + }, + }, + defaultInput: JSON.stringify({ + appId: 'b7f8c0a0-6c1d-4a9a-9c0a-2c3f1c0a3b0a', + conversationId: 'testConversationId', + authenticateParameters: { + url: 'https://localhost:4000', + width: 100, + height: 100, + isExternal: true, + }, + }), + }); + +const AuthenticateWithSSOForCEA = (): React.ReactElement => + ApiWithTextInput<{ + appId: string; + conversationId: string; + authTokenRequest: externalAppAuthentication.AuthTokenRequestParameters; + }>({ + name: 'authenticateWithSSOForCEA', + title: 'Authenticate With SSO', + onClick: { + validateInput: (input) => { + if (!input.appId) { + throw new Error('appId is required'); + } + if (!input.conversationId) { + throw new Error('conversationId is required'); + } + if (!input.authTokenRequest) { + throw new Error('authTokenRequest is required'); + } + }, + submit: async (input) => { + await externalAppAuthenticationForCEA.authenticateWithSSO( + new AppId(input.appId), + input.conversationId, + input.authTokenRequest, + ); + + return 'Completed'; + }, + }, + defaultInput: JSON.stringify({ + appId: 'b7f8c0a0-6c1d-4a9a-9c0a-2c3f1c0a3b0a', + conversationId: 'testConversationId', + authTokenRequest: { + claims: ['https://graph.microsoft.com'], + silent: true, + }, + }), + }); + +const AuthenticateAndResendRequestForCEA = (): React.ReactElement => + ApiWithTextInput<{ + appId: string; + conversationId: string; + authenticateParameters: { + url: string; + width?: number; + height?: number; + isExternal?: boolean; + }; + originalRequestInfo: externalAppAuthentication.IActionExecuteInvokeRequest; + }>({ + name: 'authenticateAndResendRequestForCEA', + title: 'Authenticate And Resend Request', + onClick: { + validateInput: (input) => { + if (!input.appId) { + throw new Error('appId is required'); + } + if (!input.conversationId) { + throw new Error('conversationId is required'); + } + if (!input.authenticateParameters) { + throw new Error('authenticateParameters is required'); + } + if (!input.originalRequestInfo) { + throw new Error('originalRequestInfo is required'); + } + }, + submit: async (input) => { + const result = await externalAppAuthenticationForCEA.authenticateAndResendRequest( + new AppId(input.appId), + input.conversationId, + { ...input.authenticateParameters, url: new URL(input.authenticateParameters.url) }, + input.originalRequestInfo, + ); + return JSON.stringify(result); + }, + }, + defaultInput: JSON.stringify({ + appId: 'b7f8c0a0-6c1d-4a9a-9c0a-2c3f1c0a3b0a', + conversationId: 'testConversationId', + authenticateParameters: { + url: 'https://localhost:4000', + width: 100, + height: 100, + isExternal: true, + }, + originalRequestInfo: { + requestType: 'ActionExecuteInvokeRequest', + type: 'Action.Execute', + id: 'id1', + verb: 'verb1', + data: 'data1', + }, + }), + }); +const AuthenticateWithSSOAndResendRequestForCEA = (): React.ReactElement => + ApiWithTextInput<{ + appId: string; + conversationId: string; + authTokenRequest: externalAppAuthentication.AuthTokenRequestParameters; + originalRequestInfo: externalAppAuthentication.IActionExecuteInvokeRequest; + }>({ + name: 'authenticateWithSSOAndResendRequestForCEA', + title: 'Authenticate With SSO And Resend Request', + onClick: { + validateInput: (input) => { + if (!input.appId) { + throw new Error('appId is required'); + } + if (!input.conversationId) { + throw new Error('conversationId is required'); + } + if (!input.authTokenRequest) { + throw new Error('authTokenRequest is required'); + } + if (!input.originalRequestInfo) { + throw new Error('originalRequestInfo is required'); + } + }, + submit: async (input) => { + const result = await externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest( + new AppId(input.appId), + input.conversationId, + input.authTokenRequest, + input.originalRequestInfo, + ); + return JSON.stringify(result); + }, + }, + defaultInput: JSON.stringify({ + appId: 'b7f8c0a0-6c1d-4a9a-9c0a-2c3f1c0a3b0a', + conversationId: 'testConversationId', + authTokenRequest: { + claims: ['https://graph.microsoft.com'], + silent: true, + }, + originalRequestInfo: { + requestType: 'ActionExecuteInvokeRequest', + type: 'Action.Execute', + id: 'id1', + verb: 'verb1', + data: 'data1', + }, + }), + }); + +const ExternalAppAuthenticationForCEAAPIs = (): React.ReactElement => ( + + + + + + + +); + +export default ExternalAppAuthenticationForCEAAPIs; diff --git a/apps/teams-test-app/src/pages/TestApp.tsx b/apps/teams-test-app/src/pages/TestApp.tsx index f8264834ae..b7f4355b73 100644 --- a/apps/teams-test-app/src/pages/TestApp.tsx +++ b/apps/teams-test-app/src/pages/TestApp.tsx @@ -40,6 +40,7 @@ import PeopleAPIs from '../components/PeopleAPIs'; import ChatAPIs from '../components/privateApis/ChatAPIs'; import CopilotAPIs from '../components/privateApis/CopilotAPIs'; import ExternalAppAuthenticationAPIs from '../components/privateApis/ExternalAppAuthenticationAPIs'; +import ExternalAppAuthenticationForCEAAPIs from '../components/privateApis/ExternalAppAuthenticationForCEAAPIs'; import ExternalAppCardActionsAPIs from '../components/privateApis/ExternalAppCardActionsAPIs'; import ExternalAppCardActionsForCEAAPIs from '../components/privateApis/ExternalAppCardActionsForCEAAPIs'; import ExternalAppCommandsAPIs from '../components/privateApis/ExternalAppCommandsAPIs'; @@ -114,6 +115,7 @@ export const TestApp: React.FC = () => { + diff --git a/change/@microsoft-teams-js-cee144c2-e3b9-40a5-bb3e-05608280d179.json b/change/@microsoft-teams-js-cee144c2-e3b9-40a5-bb3e-05608280d179.json new file mode 100644 index 0000000000..d54aa529d9 --- /dev/null +++ b/change/@microsoft-teams-js-cee144c2-e3b9-40a5-bb3e-05608280d179.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Added support for `ExternalAppAuthenticationForCEA` capability", + "packageName": "@microsoft/teams-js", + "email": "lakhveerkaur@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/teams-js/src/internal/telemetry.ts b/packages/teams-js/src/internal/telemetry.ts index 6338e8e7a6..604fbc3ee5 100644 --- a/packages/teams-js/src/internal/telemetry.ts +++ b/packages/teams-js/src/internal/telemetry.ts @@ -123,6 +123,10 @@ export const enum ApiName { ExternalAppAuthentication_AuthenticateWithSSOAndResendRequest = 'externalAppAuthentication.authenticateWithSSOAndResendRequest', ExternalAppAuthentication_AuthenticateWithOauth2 = 'externalAppAuthentication.authenticateWithOauth2', ExternalAppAuthentication_AuthenticateWithPowerPlatformConnectorPlugins = 'externalAppAuthentication.authenticateWithPowerPlatformConnectorPlugins', + ExternalAppAuthenticationForCEA_AuthenticateWithOauth = 'externalAppAuthenticationForCEA.authenticateWithOauth', + ExternalAppAuthenticationForCEA_AuthenticateWithSSO = 'externalAppAuthenticationForCEA.authenticateWithSSO', + ExternalAppAuthenticationForCEA_AuthenticateAndResendRequest = 'externalAppAuthenticationForCEA.authenticateAndResendRequest', + ExternalAppAuthenticationForCEA_AuthenticateWithSSOAndResendRequest = 'externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest', ExternalAppCardActions_ProcessActionOpenUrl = 'externalAppCardActions.processActionOpenUrl', ExternalAppCardActions_ProcessActionSubmit = 'externalAppCardActions.processActionSubmit', ExternalAppCardActionsForCEA_ProcessActionOpenUrl = 'externalAppCardActionsForCEA.processActionOpenUrl', diff --git a/packages/teams-js/src/private/externalAppAuthentication.ts b/packages/teams-js/src/private/externalAppAuthentication.ts index 96a79803f3..2af0b87855 100644 --- a/packages/teams-js/src/private/externalAppAuthentication.ts +++ b/packages/teams-js/src/private/externalAppAuthentication.ts @@ -133,13 +133,34 @@ export namespace externalAppAuthentication { data: string | Record; } + /** + * @beta + * @hidden + * Determines if the provided response object is an instance of IActionExecuteResponse + * @internal + * Limited to Microsoft-internal use + * @param response The object to check whether it is of IActionExecuteResponse type + */ + export function isActionExecuteResponse( + response: unknown, + ): response is externalAppAuthentication.IActionExecuteResponse { + const actionResponse = response as externalAppAuthentication.IActionExecuteResponse; + + return ( + actionResponse.responseType === externalAppAuthentication.InvokeResponseType.ActionExecuteInvokeResponse && + actionResponse.value !== undefined && + actionResponse.statusCode !== undefined && + actionResponse.type !== undefined + ); + } + /** * @hidden * This is the only allowed value for IActionExecuteInvokeRequest.type. Used for validation * @internal * Limited to Microsoft-internal use */ - const ActionExecuteInvokeRequestType = 'Action.Execute'; + export const ActionExecuteInvokeRequestType = 'Action.Execute'; /** * @hidden @@ -279,6 +300,27 @@ export namespace externalAppAuthentication { message?: string; } + /** + * @beta + * @hidden + * Determines if the provided error object is an instance of InvokeError + * @internal + * Limited to Microsoft-internal use + * @param err The error object to check whether it is of InvokeError type + */ + export function isInvokeError(err: unknown): err is externalAppAuthentication.InvokeError { + if (typeof err !== 'object' || err === null) { + return false; + } + + const error = err as externalAppAuthentication.InvokeError; + + return ( + Object.values(externalAppAuthentication.InvokeErrorCode).includes(error.errorCode) && + (error.message === undefined || typeof error.message === 'string') + ); + } + /** * @hidden * @@ -295,7 +337,8 @@ export namespace externalAppAuthentication { * @internal * Limited to Microsoft-internal use */ - type InvokeErrorWrapper = InvokeError & { responseType: undefined }; + export type InvokeErrorWrapper = InvokeError & { responseType: undefined }; + /*********** END ERROR TYPE ***********/ /** diff --git a/packages/teams-js/src/private/externalAppAuthenticationForCEA.ts b/packages/teams-js/src/private/externalAppAuthenticationForCEA.ts new file mode 100644 index 0000000000..0d988a722d --- /dev/null +++ b/packages/teams-js/src/private/externalAppAuthenticationForCEA.ts @@ -0,0 +1,249 @@ +import { sendAndUnwrap } from '../internal/communication'; +import { ensureInitialized } from '../internal/internalAPIs'; +import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry'; +import { validateId } from '../internal/utils'; +import { AppId } from '../public'; +import { errorNotSupportedOnPlatform, FrameContexts } from '../public/constants'; +import { runtime } from '../public/runtime'; +import { externalAppAuthentication } from './externalAppAuthentication'; + +const externalAppAuthenticationTelemetryVersionNumber: ApiVersionNumber = ApiVersionNumber.V_2; + +/** + * @hidden + * Namespace to delegate authentication requests to the host for custom engine agents + * @internal + * Limited to Microsoft-internal use + * @beta + */ +export namespace externalAppAuthenticationForCEA { + /** + * @beta + * @hidden + * Signals to the host to perform SSO authentication for the application specified by the app ID, and then send the authResult to the application backend. + * @internal + * Limited to Microsoft-internal use + * @param appId App ID of the app upon whose behalf Copilot is requesting authentication. This must be a UUID. + * @param conversationId ConversationId To tell the bot what conversation the calls are coming from + * @param authTokenRequest Parameters for SSO authentication + * @throws InvokeError if the host encounters an error while authenticating + * @returns A promise that resolves when authentication succeeds and rejects with InvokeError on failure + */ + export async function authenticateWithSSO( + appId: AppId, + conversationId: string, + authTokenRequest: externalAppAuthentication.AuthTokenRequestParameters, + ): Promise { + ensureInitialized(runtime, FrameContexts.content); + + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + + validateId(conversationId, new Error('conversation id is not valid.')); + + const error = await sendAndUnwrap( + getApiVersionTag( + externalAppAuthenticationTelemetryVersionNumber, + ApiName.ExternalAppAuthenticationForCEA_AuthenticateWithSSO, + ), + ApiName.ExternalAppAuthenticationForCEA_AuthenticateWithSSO, + appId.toString(), + conversationId, + authTokenRequest.claims, + authTokenRequest.silent, + ); + if (error) { + throw error; + } + } + + /** + * @beta + * @hidden + * Signals to the host to perform authentication using the given authentication parameters and then send the auth result to the application backend. + * @internal + * Limited to Microsoft-internal use + * @param appId App ID of the app upon whose behalf Copilot is requesting authentication. This must be a UUID. + * @param conversationId ConversationId To tell the bot what conversation the calls are coming from + * @param authenticateParameters Parameters for the authentication pop-up + * @throws InvokeError if the host encounters an error while authenticating + * @returns A promise that resolves from the application backend and rejects with InvokeError if the host encounters an error while authenticating + */ + export async function authenticateWithOauth( + appId: AppId, + conversationId: string, + authenticateParameters: externalAppAuthentication.AuthenticatePopUpParameters, + ): Promise { + ensureInitialized(runtime, FrameContexts.content); + + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + + validateId(conversationId, new Error('conversation id is not valid.')); + + // Ask the parent window to open an authentication window with the parameters provided by the caller. + const error = await sendAndUnwrap( + getApiVersionTag( + externalAppAuthenticationTelemetryVersionNumber, + ApiName.ExternalAppAuthenticationForCEA_AuthenticateWithOauth, + ), + ApiName.ExternalAppAuthenticationForCEA_AuthenticateWithOauth, + appId.toString(), + conversationId, + authenticateParameters.url.href, + authenticateParameters.width, + authenticateParameters.height, + authenticateParameters.isExternal, + ); + if (error) { + throw error; + } + } + + /** + * @beta + * @hidden + * Signals to the host to perform authentication using the given authentication parameters and then resend the request to the application backend with the authentication result. + * @internal + * Limited to Microsoft-internal use + * @param appId App ID of the app upon whose behalf Copilot is requesting authentication. This must be a UUID. + * @param conversationId ConversationId To tell the bot what conversation the calls are coming from + * @param authenticateParameters Parameters for the authentication pop-up + * @param originalRequestInfo Information about the original request that should be resent + * @throws InvokeError if the host encounters an error while authenticating or resending the request + * @returns A promise that resolves to the IActionExecuteResponse from the application backend and rejects with InvokeError if the host encounters an error while authenticating or resending the request + */ + export async function authenticateAndResendRequest( + appId: AppId, + conversationId: string, + authenticateParameters: externalAppAuthentication.AuthenticatePopUpParameters, + originalRequestInfo: externalAppAuthentication.IActionExecuteInvokeRequest, + ): Promise { + ensureInitialized(runtime, FrameContexts.content); + + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + + validateId(conversationId, new Error('conversation id is not valid.')); + + validateOriginalRequestInfo(originalRequestInfo); + + // Ask the parent window to open an authentication window with the parameters provided by the caller. + const response = await sendAndUnwrap< + externalAppAuthentication.InvokeError | externalAppAuthentication.IActionExecuteResponse + >( + getApiVersionTag( + externalAppAuthenticationTelemetryVersionNumber, + ApiName.ExternalAppAuthenticationForCEA_AuthenticateAndResendRequest, + ), + ApiName.ExternalAppAuthenticationForCEA_AuthenticateAndResendRequest, + appId.toString(), + conversationId, + originalRequestInfo, + authenticateParameters.url.href, + authenticateParameters.width, + authenticateParameters.height, + authenticateParameters.isExternal, + ); + if (externalAppAuthentication.isActionExecuteResponse(response)) { + return response; + } else { + throw externalAppAuthentication.isInvokeError(response) ? response : defaultExternalAppError; + } + } + + /** + * @beta + * @hidden + * Signals to the host to perform SSO authentication for the application specified by the app ID and then resend the request to the application backend with the authentication result and originalRequestInfo + * @internal + * Limited to Microsoft-internal use + * @param appId App ID of the app upon whose behalf Copilot is requesting authentication. This must be a UUID. + * @param conversationId ConversationId To tell the bot what conversation the calls are coming from + * @param authTokenRequest Parameters for SSO authentication + * @param originalRequestInfo Information about the original request that should be resent + * @throws InvokeError if the host encounters an error while authenticating or resending the request + * @returns A promise that resolves to the IActionExecuteResponse from the application backend and rejects with InvokeError if the host encounters an error while authenticating or resending the request + */ + export async function authenticateWithSSOAndResendRequest( + appId: AppId, + conversationId: string, + authTokenRequest: externalAppAuthentication.AuthTokenRequestParameters, + originalRequestInfo: externalAppAuthentication.IActionExecuteInvokeRequest, + ): Promise { + ensureInitialized(runtime, FrameContexts.content); + + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + + validateId(conversationId, new Error('conversation id is not valid.')); + + validateOriginalRequestInfo(originalRequestInfo); + + const response = await sendAndUnwrap< + externalAppAuthentication.IActionExecuteResponse | externalAppAuthentication.InvokeError + >( + getApiVersionTag( + externalAppAuthenticationTelemetryVersionNumber, + ApiName.ExternalAppAuthenticationForCEA_AuthenticateWithSSOAndResendRequest, + ), + ApiName.ExternalAppAuthenticationForCEA_AuthenticateWithSSOAndResendRequest, + appId.toString(), + conversationId, + originalRequestInfo, + authTokenRequest.claims, + authTokenRequest.silent, + ); + if (externalAppAuthentication.isActionExecuteResponse(response)) { + return response; + } else { + throw externalAppAuthentication.isInvokeError(response) ? response : defaultExternalAppError; + } + } + + /** + * @beta + * @hidden + * Checks if the externalAppAuthenticationForCEA capability is supported by the host + * @returns boolean to represent whether externalAppAuthenticationForCEA capability is supported + * @throws Error if {@linkcode app.initialize} has not successfully completed + * @internal + * Limited to Microsoft-internal use + */ + export function isSupported(): boolean { + return ensureInitialized(runtime) && runtime.supports.externalAppAuthenticationForCEA ? true : false; + } + + /** + * @hidden + * @internal + * Limited to Microsoft-internal use + * @beta + */ + function validateOriginalRequestInfo( + actionExecuteRequest: externalAppAuthentication.IActionExecuteInvokeRequest, + ): void { + if (actionExecuteRequest.type !== externalAppAuthentication.ActionExecuteInvokeRequestType) { + const error: externalAppAuthentication.InvokeError = { + errorCode: externalAppAuthentication.InvokeErrorCode.INTERNAL_ERROR, + message: `Invalid action type ${actionExecuteRequest.type}. Action type must be "${externalAppAuthentication.ActionExecuteInvokeRequestType}"`, + }; + throw error; + } + } + + /** + * @hidden + * @internal + * Limited to Microsoft-internal use + * @beta + */ + const defaultExternalAppError = { + errorCode: externalAppAuthentication.InvokeErrorCode.INTERNAL_ERROR, + message: 'No valid response received', + }; +} diff --git a/packages/teams-js/src/private/index.ts b/packages/teams-js/src/private/index.ts index 64aeb871a3..52b6a26a8d 100644 --- a/packages/teams-js/src/private/index.ts +++ b/packages/teams-js/src/private/index.ts @@ -21,6 +21,7 @@ export { export { conversations } from './conversations'; export { copilot } from './copilot'; export { externalAppAuthentication } from './externalAppAuthentication'; +export { externalAppAuthenticationForCEA } from './externalAppAuthenticationForCEA'; export { externalAppCardActions } from './externalAppCardActions'; export { externalAppCardActionsForCEA } from './externalAppCardActionsForCEA'; export { externalAppCommands } from './externalAppCommands'; diff --git a/packages/teams-js/src/public/runtime.ts b/packages/teams-js/src/public/runtime.ts index 4aa5e93815..794ae06e42 100644 --- a/packages/teams-js/src/public/runtime.ts +++ b/packages/teams-js/src/public/runtime.ts @@ -242,6 +242,7 @@ interface IRuntimeV4 extends IBaseRuntime { readonly update?: {}; }; readonly externalAppAuthentication?: {}; + readonly externalAppAuthenticationForCEA?: {}; readonly externalAppCardActions?: {}; readonly externalAppCardActionsForCEA?: {}; readonly externalAppCommands?: {}; diff --git a/packages/teams-js/test/private/externalAppAuthenticationForCEA.spec.ts b/packages/teams-js/test/private/externalAppAuthenticationForCEA.spec.ts new file mode 100644 index 0000000000..d35a69ef41 --- /dev/null +++ b/packages/teams-js/test/private/externalAppAuthenticationForCEA.spec.ts @@ -0,0 +1,623 @@ +import { errorLibraryNotInitialized } from '../../src/internal/constants'; +import { GlobalVars } from '../../src/internal/globalVars'; +import { externalAppAuthentication } from '../../src/private/externalAppAuthentication'; +import { externalAppAuthenticationForCEA } from '../../src/private/externalAppAuthenticationForCEA'; +import { AppId } from '../../src/public'; +import { app } from '../../src/public/app'; +import { errorNotSupportedOnPlatform, FrameContexts } from '../../src/public/constants'; +import { Utils } from '../utils'; + +describe('externalAppAuthenticationForCEA', () => { + let utils = new Utils(); + + beforeEach(() => { + utils = new Utils(); + utils.mockWindow.parent = undefined; + utils.messages = []; + GlobalVars.isFramelessWindow = false; + }); + + afterEach(() => { + app._uninitialize(); + jest.clearAllMocks(); + }); + + // These IDs were randomly generated for the purpose of these tests + const stringified = '01b92759-b43a-4085-ac22-7772d94bb7a9'; + const testAppId = new AppId(stringified); + const testConversationId = '01b92759-b43a-4085-ac22-777777777777'; + + const testOriginalRequest: externalAppAuthentication.IActionExecuteInvokeRequest = { + requestType: externalAppAuthentication.OriginalRequestType.ActionExecuteInvokeRequest, + type: 'Action.Execute', + id: '1', + verb: 'action', + data: {}, + }; + const testOriginalRequestWithInvalidType: externalAppAuthentication.IActionExecuteInvokeRequest = { + requestType: externalAppAuthentication.OriginalRequestType.ActionExecuteInvokeRequest, + type: 'INVALID_TYPE', + id: '1', + verb: 'action', + data: {}, + }; + + describe('authenticateAndResendRequest', () => { + const testAuthRequest = { + url: new URL('https://example.com'), + width: 100, + height: 100, + isExternal: true, + }; + const testResponse = { + responseType: externalAppAuthentication.InvokeResponseType.ActionExecuteInvokeResponse, + value: {}, + signature: 'test signature', + statusCode: 200, + type: 'test type', + }; + const testError = { + errorCode: 'INTERNAL_ERROR', + message: 'test error message', + }; + const allowedFrameContexts = [FrameContexts.content]; + + it('should not allow calls before initialization', async () => { + expect.assertions(1); + + try { + await externalAppAuthenticationForCEA.authenticateAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + } catch (e) { + expect(e).toEqual(new Error(errorLibraryNotInitialized)); + } + }); + + it('should throw error when externalAppAuthenticationForCEA is not supported in runtime config.', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: {} }); + expect.assertions(1); + try { + await externalAppAuthenticationForCEA.authenticateAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + } catch (e) { + expect(e).toEqual(errorNotSupportedOnPlatform); + } + }); + + Object.values(FrameContexts).forEach((frameContext) => { + if (allowedFrameContexts.includes(frameContext)) { + it(`should return response on success with context - ${frameContext}`, async () => { + expect.assertions(3); + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const promise = externalAppAuthenticationForCEA.authenticateAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + const message = utils.findMessageByFunc('externalAppAuthenticationForCEA.authenticateAndResendRequest'); + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testOriginalRequest, + testAuthRequest.url.href, + testAuthRequest.width, + testAuthRequest.height, + testAuthRequest.isExternal, + ]); + // eslint-disable-next-line strict-null-checks/all + utils.respondToMessage(message, testResponse); + } + return expect(promise).resolves.toEqual(testResponse); + }); + it(`should throw error on invalid original request with context - ${frameContext}`, async () => { + expect.assertions(1); + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + try { + await externalAppAuthenticationForCEA.authenticateAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequestWithInvalidType, + ); + } catch (e) { + expect(e).toEqual({ + errorCode: 'INTERNAL_ERROR', + message: `Invalid action type ${testOriginalRequestWithInvalidType.type}. Action type must be "Action.Execute"`, + }); + } + }); + it(`should throw error from host on failure with context - ${frameContext}`, async () => { + expect.assertions(3); + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const promise = externalAppAuthenticationForCEA.authenticateAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + const message = utils.findMessageByFunc('externalAppAuthenticationForCEA.authenticateAndResendRequest'); + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testOriginalRequest, + testAuthRequest.url.href, + testAuthRequest.width, + testAuthRequest.height, + testAuthRequest.isExternal, + ]); + utils.respondToMessage(message, testError); + } + return expect(promise).rejects.toEqual(testError); + }); + it(`should throw default error when host sends a response that does not fit InvokeError or ActionExecuteResponse - ${frameContext}`, async () => { + expect.assertions(3); + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const promise = externalAppAuthenticationForCEA.authenticateAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + const message = utils.findMessageByFunc('externalAppAuthenticationForCEA.authenticateAndResendRequest'); + const testInvalidResponse = { + responseType: 'INVALID_RESPONSE_TYPE', + }; + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testOriginalRequest, + testAuthRequest.url.href, + testAuthRequest.width, + testAuthRequest.height, + testAuthRequest.isExternal, + ]); + utils.respondToMessage(message, testInvalidResponse); + } + return expect(promise).rejects.toEqual({ + errorCode: 'INTERNAL_ERROR', + message: 'No valid response received', + }); + }); + } else { + it(`should not allow calls from ${frameContext} context`, async () => { + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + + await expect( + externalAppAuthenticationForCEA.authenticateAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ), + ).rejects.toThrow( + new Error( + `This call is only allowed in following contexts: ${JSON.stringify(allowedFrameContexts)}. ` + + `Current context: "${frameContext}".`, + ), + ); + }); + } + }); + }); + + describe('authenticateWithSSO', () => { + const testRequest = { + claims: ['claims'], + silent: true, + }; + const allowedFrameContexts = [FrameContexts.content]; + + it('should not allow calls before initialization', async () => { + expect.assertions(1); + + try { + await externalAppAuthenticationForCEA.authenticateWithSSO(testAppId, testConversationId, {}); + } catch (e) { + expect(e).toEqual(new Error(errorLibraryNotInitialized)); + } + }); + + it('should throw error when externalAppAuthenticationForCEA is not supported in runtime config.', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: {} }); + expect.assertions(1); + try { + await externalAppAuthenticationForCEA.authenticateWithSSO(testAppId, testConversationId, {}); + } catch (e) { + expect(e).toEqual(errorNotSupportedOnPlatform); + } + }); + Object.values(FrameContexts).forEach((frameContext) => { + if (allowedFrameContexts.includes(frameContext)) { + it('should throw error from host', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const testError = { + errorCode: 'INTERNAL_ERROR', + message: 'test error message', + }; + const promise = externalAppAuthenticationForCEA.authenticateWithSSO( + testAppId, + testConversationId, + testRequest, + ); + + const message = utils.findMessageByFunc('externalAppAuthenticationForCEA.authenticateWithSSO'); + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testRequest.claims, + testRequest.silent, + ]); + utils.respondToMessage(message, testError); + } + await expect(promise).rejects.toEqual(testError); + }); + it('should resolve on success', async () => { + expect.assertions(3); + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const promise = externalAppAuthenticationForCEA.authenticateWithSSO( + testAppId, + testConversationId, + testRequest, + ); + + const message = utils.findMessageByFunc('externalAppAuthenticationForCEA.authenticateWithSSO'); + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testRequest.claims, + testRequest.silent, + ]); + utils.respondToMessage(message); + } + await expect(promise).resolves.toBeUndefined(); + }); + } else { + it(`should not allow calls from ${frameContext} context`, async () => { + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + + await expect( + externalAppAuthenticationForCEA.authenticateWithSSO(testAppId, testConversationId, testRequest), + ).rejects.toThrow( + new Error( + `This call is only allowed in following contexts: ${JSON.stringify(allowedFrameContexts)}. ` + + `Current context: "${frameContext}".`, + ), + ); + }); + } + }); + }); + + describe('authenticateWithSSOAndResendRequest', () => { + const testAuthRequest = { + claims: ['claims'], + silent: true, + }; + it('should not allow calls before initialization', async () => { + expect.assertions(1); + + try { + await externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + } catch (e) { + expect(e).toEqual(new Error(errorLibraryNotInitialized)); + } + }); + + it('should throw error when externalAppAuthenticationForCEA is not supported in runtime config.', async () => { + expect.assertions(1); + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: {} }); + try { + await externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + } catch (e) { + expect(e).toEqual(errorNotSupportedOnPlatform); + } + }); + const allowedFrameContexts = [FrameContexts.content]; + Object.values(FrameContexts).forEach((frameContext) => { + if (allowedFrameContexts.includes(frameContext)) { + it(`should throw error from host failure in context - ${frameContext}`, async () => { + expect.assertions(3); + const testError = { + errorCode: 'INTERNAL_ERROR', + message: 'test error message', + }; + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const promise = externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + + const message = utils.findMessageByFunc( + 'externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest', + ); + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testOriginalRequest, + testAuthRequest.claims, + testAuthRequest.silent, + ]); + // eslint-disable-next-line strict-null-checks/all + utils.respondToMessage(message, testError); + } + await expect(promise).rejects.toEqual(testError); + }); + + it(`should throw error from host failure in context - ${frameContext}`, async () => { + expect.assertions(3); + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const promise = externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + + const message = utils.findMessageByFunc( + 'externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest', + ); + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testOriginalRequest, + testAuthRequest.claims, + testAuthRequest.silent, + ]); + const invalidTestError = { + invalidError: 'invalidError', + }; + // eslint-disable-next-line strict-null-checks/all + utils.respondToMessage(message, invalidTestError); + } + await expect(promise).rejects.toEqual({ + errorCode: 'INTERNAL_ERROR', + message: 'No valid response received', + }); + }); + + it(`should throw error on invalid original request with context - ${frameContext}`, async () => { + expect.assertions(1); + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + try { + await externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequestWithInvalidType, + ); + } catch (e) { + expect(e).toEqual({ + errorCode: 'INTERNAL_ERROR', + message: `Invalid action type ${testOriginalRequestWithInvalidType.type}. Action type must be "Action.Execute"`, + }); + } + }); + it(`should return response on success in context - ${frameContext}`, async () => { + expect.assertions(3); + const testResponse = { + responseType: externalAppAuthentication.InvokeResponseType.ActionExecuteInvokeResponse, + value: {}, + signature: 'test signature', + statusCode: 200, + type: 'test type', + }; + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const promise = externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ); + + const message = utils.findMessageByFunc( + 'externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest', + ); + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testOriginalRequest, + testAuthRequest.claims, + testAuthRequest.silent, + ]); + // eslint-disable-next-line strict-null-checks/all + utils.respondToMessage(message, testResponse); + } + await expect(promise).resolves.toEqual(testResponse); + }); + } else { + it(`should not allow calls from ${frameContext} context`, async () => { + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + await expect(() => + externalAppAuthenticationForCEA.authenticateWithSSOAndResendRequest( + testAppId, + testConversationId, + testAuthRequest, + testOriginalRequest, + ), + ).rejects.toThrow( + new Error( + `This call is only allowed in following contexts: ${JSON.stringify(allowedFrameContexts)}. ` + + `Current context: "${frameContext}".`, + ), + ); + }); + } + }); + }); + + describe('authenticateWithOauth', () => { + const testAuthRequest = { + url: new URL('https://example.com'), + width: 100, + height: 100, + isExternal: true, + }; + const allowedFrameContexts = [FrameContexts.content]; + it('should not allow calls before initialization', async () => { + expect.assertions(1); + + try { + await externalAppAuthenticationForCEA.authenticateWithOauth(testAppId, testConversationId, testAuthRequest); + } catch (e) { + expect(e).toEqual(new Error(errorLibraryNotInitialized)); + } + }); + + it('should throw error when externalAppAuthenticationForCEA is not supported in runtime config.', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: {} }); + expect.assertions(1); + try { + await externalAppAuthenticationForCEA.authenticateWithOauth(testAppId, testConversationId, testAuthRequest); + } catch (e) { + expect(e).toEqual(errorNotSupportedOnPlatform); + } + }); + + Object.values(FrameContexts).forEach((frameContext) => { + if (allowedFrameContexts.includes(frameContext)) { + it(`should resolve on success with context - ${frameContext}`, async () => { + expect.assertions(3); + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const promise = externalAppAuthenticationForCEA.authenticateWithOauth( + testAppId, + testConversationId, + testAuthRequest, + ); + const message = utils.findMessageByFunc('externalAppAuthenticationForCEA.authenticateWithOauth'); + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testAuthRequest.url.href, + testAuthRequest.width, + testAuthRequest.height, + testAuthRequest.isExternal, + ]); + utils.respondToMessage(message); + } + await expect(promise).resolves.toBeUndefined(); + }); + it('should throw error from host', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + const testError = { + errorCode: 'INTERNAL_ERROR', + message: 'test error message', + }; + const promise = externalAppAuthenticationForCEA.authenticateWithOauth( + testAppId, + testConversationId, + testAuthRequest, + ); + const message = utils.findMessageByFunc('externalAppAuthenticationForCEA.authenticateWithOauth'); + if (message && message.args) { + expect(message).not.toBeNull(); + expect(message.args).toEqual([ + testAppId.toString(), + testConversationId, + testAuthRequest.url.href, + testAuthRequest.width, + testAuthRequest.height, + testAuthRequest.isExternal, + ]); + utils.respondToMessage(message, testError); + } + await expect(promise).rejects.toEqual(testError); + }); + } else { + it(`should not allow calls from ${frameContext} context`, async () => { + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + await expect( + externalAppAuthenticationForCEA.authenticateWithOauth(testAppId, testConversationId, testAuthRequest), + ).rejects.toThrow( + new Error( + `This call is only allowed in following contexts: ${JSON.stringify(allowedFrameContexts)}. ` + + `Current context: "${frameContext}".`, + ), + ); + }); + } + }); + }); + describe('isSupported', () => { + it('should not allow calls before initialization', async () => { + expect.assertions(1); + + try { + externalAppAuthenticationForCEA.isSupported(); + } catch (e) { + expect(e).toEqual(new Error(errorLibraryNotInitialized)); + } + }); + + it('should return true when externalAppCardActions capability is supported', async () => { + expect.assertions(1); + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: { externalAppAuthenticationForCEA: {} } }); + return expect(externalAppAuthenticationForCEA.isSupported()).toEqual(true); + }); + it('should return false when externalAppCardActions capability is not supported', async () => { + expect.assertions(1); + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 2, supports: {} }); + return expect(externalAppAuthenticationForCEA.isSupported()).toEqual(false); + }); + }); +});