diff --git a/src/web/client/WebExtensionContext.ts b/src/web/client/WebExtensionContext.ts index a80167b5..68a0b654 100644 --- a/src/web/client/WebExtensionContext.ts +++ b/src/web/client/WebExtensionContext.ts @@ -104,7 +104,7 @@ class WebExtensionContext implements IWebExtensionContext { private _npsEligibility: boolean; private _userId: string; private _formsProEligibilityId: string; - private _concurrencyHandler: ConcurrencyHandler + private _concurrencyHandler: ConcurrencyHandler; // Co-Presence for Power Pages Vscode for web private _worker: Worker | undefined; private _sharedWorkSpaceMap: Map; diff --git a/src/web/client/common/authenticationProvider.ts b/src/web/client/common/authenticationProvider.ts index 495e253f..85c01323 100644 --- a/src/web/client/common/authenticationProvider.ts +++ b/src/web/client/common/authenticationProvider.ts @@ -9,8 +9,10 @@ import { telemetryEventNames } from "../telemetry/constants"; import { INTELLIGENCE_SCOPE_DEFAULT, PROVIDER_ID, + SCOPE_OPTION_CONTACTS_READ, SCOPE_OPTION_DEFAULT, SCOPE_OPTION_OFFLINE_ACCESS, + SCOPE_OPTION_USERS_READ_BASIC_ALL, } from "./constants"; import { ERRORS, showErrorDialog } from "./errorHandler"; import { ITelemetry } from "../../../client/telemetry/ITelemetry"; @@ -161,3 +163,62 @@ export async function npsAuthentication( return accessToken; } + +export async function graphClientAuthentication( + firstTimeAuth = false +): Promise { + let accessToken = ""; + try { + let session = await vscode.authentication.getSession( + PROVIDER_ID, + [ + SCOPE_OPTION_CONTACTS_READ, + SCOPE_OPTION_USERS_READ_BASIC_ALL, + ], + { silent: true } + ); + + if (!session) { + session = await vscode.authentication.getSession( + PROVIDER_ID, + [ + SCOPE_OPTION_CONTACTS_READ, + SCOPE_OPTION_USERS_READ_BASIC_ALL, + ], + { createIfNone: true } + ); + } + + accessToken = session?.accessToken ?? ""; + if (!accessToken) { + throw new Error(ERRORS.NO_ACCESS_TOKEN); + } + + if (firstTimeAuth) { + WebExtensionContext.telemetry.sendInfoTelemetry( + telemetryEventNames.WEB_EXTENSION_GRAPH_CLIENT_AUTHENTICATION_COMPLETED, + { + userId: + session?.account.id.split("/").pop() ?? + session?.account.id ?? + "", + } + ); + } + } catch (error) { + const authError = (error as Error)?.message; + showErrorDialog( + vscode.l10n.t( + "Authorization Failed. Please run again to authorize it" + ), + vscode.l10n.t("There was a permissions problem with the server") + ); + WebExtensionContext.telemetry.sendErrorTelemetry( + telemetryEventNames.WEB_EXTENSION_GRAPH_CLIENT_AUTHENTICATION_FAILED, + graphClientAuthentication.name, + authError + ); + } + + return accessToken; +} diff --git a/src/web/client/common/constants.ts b/src/web/client/common/constants.ts index 513c8ccc..63ba7de6 100644 --- a/src/web/client/common/constants.ts +++ b/src/web/client/common/constants.ts @@ -28,11 +28,12 @@ export const MAX_CONCURRENT_REQUEST_QUEUE_COUNT = 1000; export const INTELLIGENCE_SCOPE_DEFAULT = "https://text.pai.dynamics.com/.default"; export const BACK_TO_STUDIO_URL_TEMPLATE = "https://make{.region}.powerpages.microsoft.com/e/{environmentId}/sites/{webSiteId}/pages"; export const STUDIO_PROD_REGION = "prod"; -export const ARTEMIS_RESPONSE_FAILED = "Artemis response failed" -export const WEB_EXTENSION_FETCH_WORKER_SCRIPT_FAILED = "Web extension fetch worker script failed" -export const WEB_EXTENSION_POPULATE_SHARED_WORKSPACE_SYSTEM_ERROR = "Web extension populate shared workspace system error" -export const WEB_EXTENSION_WEB_WORKER_REGISTRATION_FAILED = "Web extension web worker registration failed" -export const WEB_EXTENSION_FETCH_GET_OR_CREATE_SHARED_WORK_SPACE_ERROR = "Web extension fetch get or create shared workspace error" +export const ARTEMIS_RESPONSE_FAILED = "Artemis response failed"; +export const WEB_EXTENSION_GET_FROM_GRAPH_CLIENT_FAILED = "Web extension get from graph client failed"; +export const WEB_EXTENSION_FETCH_WORKER_SCRIPT_FAILED = "Web extension fetch worker script failed"; +export const WEB_EXTENSION_POPULATE_SHARED_WORKSPACE_SYSTEM_ERROR = "Web extension populate shared workspace system error"; +export const WEB_EXTENSION_WEB_WORKER_REGISTRATION_FAILED = "Web extension web worker registration failed"; +export const WEB_EXTENSION_FETCH_GET_OR_CREATE_SHARED_WORK_SPACE_ERROR = "Web extension fetch get or create shared workspace error"; // Web extension constants export const BASE_64 = 'base64'; @@ -117,3 +118,16 @@ export enum portalSchemaVersion { V1 = "portalschemav1", V2 = "portalschemav2", } + +// Microsoft Graph Client constants +export const SCOPE_OPTION_CONTACTS_READ = "Contacts.Read"; +export const SCOPE_OPTION_USERS_READ_BASIC_ALL = "User.ReadBasic.All"; + +export const MICROSOFT_GRAPH_USERS_BASE_URL = "https://graph.microsoft.com/v1.0/users/"; + +export enum GraphService { + GRAPH_MAIL = "mail", + GRAPH_PROFILE_PICTURE = "profilePicture", +} + +export const MICROSOFT_GRAPH_PROFILE_PICTURE_SERVICE_CALL = "/photo/$value"; diff --git a/src/web/client/services/graphClientService.ts b/src/web/client/services/graphClientService.ts new file mode 100644 index 00000000..ce6c4945 --- /dev/null +++ b/src/web/client/services/graphClientService.ts @@ -0,0 +1,152 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import path from "path"; +import WebExtensionContext from "../WebExtensionContext"; +import { getCommonHeaders, graphClientAuthentication } from "../common/authenticationProvider"; +import * as Constants from "../common/constants"; +import { telemetryEventNames } from "../telemetry/constants"; + +export class GraphClientService { + private _graphToken: string; + + constructor() { + this._graphToken = ""; + } + + get graphToken() { + return this._graphToken; + } + + public async graphClientAuthentication(firstTimeAuth = false) { + const accessToken = await graphClientAuthentication(firstTimeAuth); + this._graphToken = accessToken; + } + + private async requestGraphClient( + service: string, + userId: string + ) { + let requestSentAtTime = new Date().getTime(); + + const basePath = Constants.MICROSOFT_GRAPH_USERS_BASE_URL; + let requestUrl; + + switch (service) { + case Constants.GraphService.GRAPH_MAIL: + requestUrl = new URL(userId, basePath); + break; + case Constants.GraphService.GRAPH_PROFILE_PICTURE: + requestUrl = new URL( + path.join( + userId, + Constants.MICROSOFT_GRAPH_PROFILE_PICTURE_SERVICE_CALL + ), + basePath + ); + break; + default: + return; + } + + try { + WebExtensionContext.telemetry.sendAPITelemetry( + requestUrl.href, + service, + Constants.httpMethod.GET, + this.requestGraphClient.name + ); + + requestSentAtTime = new Date().getTime(); + + const response = + await WebExtensionContext.concurrencyHandler.handleRequest( + requestUrl.href, + { + headers: { + ...getCommonHeaders(this._graphToken), + }, + } + ); + + if (!response.ok) { + throw new Error(JSON.stringify(response)); + } + + WebExtensionContext.telemetry.sendAPISuccessTelemetry( + requestUrl.href, + service, + Constants.httpMethod.POST, + new Date().getTime() - requestSentAtTime, + this.requestGraphClient.name + ); + + return await response.json(); + } catch (error) { + const errorMsg = (error as Error)?.message; + if ((error as Response)?.status > 0) { + WebExtensionContext.telemetry.sendAPIFailureTelemetry( + requestUrl.href, + service, + Constants.httpMethod.GET, + new Date().getTime() - requestSentAtTime, + this.requestGraphClient.name, + errorMsg, + "", + (error as Response)?.status.toString() + ); + } else { + WebExtensionContext.telemetry.sendErrorTelemetry( + telemetryEventNames.WEB_EXTENSION_GET_FROM_GRAPH_CLIENT_FAILED, + this.requestGraphClient.name, + Constants.WEB_EXTENSION_GET_FROM_GRAPH_CLIENT_FAILED, + error as Error + ); + } + } + } + + public async getUserEmail(userId: string) { + try { + if (!this._graphToken) { + await this.graphClientAuthentication(true); + } + + const response = await this.requestGraphClient( + Constants.GraphService.GRAPH_MAIL, + userId + ); + return await response.mail; + } catch (error) { + WebExtensionContext.telemetry.sendErrorTelemetry( + telemetryEventNames.WEB_EXTENSION_GET_EMAIL_FROM_GRAPH_CLIENT_FAILED, + this.getUserEmail.name, + (error as Error)?.message, + error as Error + ); + } + } + + public async getUserProfilePicture(userId: string) { + try { + if (!this._graphToken) { + await this.graphClientAuthentication(true); + } + + const response = await this.requestGraphClient( + Constants.GraphService.GRAPH_PROFILE_PICTURE, + userId + ); + return await response; + } catch (error) { + WebExtensionContext.telemetry.sendErrorTelemetry( + telemetryEventNames.WEB_EXTENSION_GET_PROFILE_PICTURE_FROM_GRAPH_CLIENT_FAILED, + this.getUserProfilePicture.name, + (error as Error)?.message, + error as Error + ); + } + } +} diff --git a/src/web/client/telemetry/constants.ts b/src/web/client/telemetry/constants.ts index 83ba6345..abefbebb 100644 --- a/src/web/client/telemetry/constants.ts +++ b/src/web/client/telemetry/constants.ts @@ -110,4 +110,9 @@ export enum telemetryEventNames { WEB_EXTENSION_ARTEMIS_RESPONSE = 'webExtensionArtemisResponse', WEB_EXTENSION_ARTEMIS_RESPONSE_FAILED = 'webExtensionArtemisResponseFailed', WEB_EXTENSION_FAILED_TO_UPDATE_FOREIGN_KEY_DETAILS = 'webExtensionFailedToUpdateForeignKeyDetails', + WEB_EXTENSION_GRAPH_CLIENT_AUTHENTICATION_FAILED = "WebExtensionGraphClientAuthenticationFailed", + WEB_EXTENSION_GRAPH_CLIENT_AUTHENTICATION_COMPLETED = "WebExtensionGraphClientAuthenticationCompleted", + WEB_EXTENSION_GET_FROM_GRAPH_CLIENT_FAILED = "WebExtensionGetFromGraphClientFailed", + WEB_EXTENSION_GET_EMAIL_FROM_GRAPH_CLIENT_FAILED = "WebExtensionGetEmailFromGraphClientFailed", + WEB_EXTENSION_GET_PROFILE_PICTURE_FROM_GRAPH_CLIENT_FAILED = "WebExtensionGetProfilePictureFromGraphClientFailed", }