with TypeScript SSO

Open File Explorer and create the (C:\temp\vscode) folder.
Open Visual Studio Code.
Display the Terminal window (View > Terminal).
Change to that folder.

cd C:\temp\vscode-yeoman 

Run the Yeoman generator to create the project.

yo office 

Use the Arrow Keys to select the project type.
Choose "Office Add-in Task Pane project supporting single sign-on".
Press Enter to select.

alt text

Choose a script type: Select "TypeScript".
What do you want to name your add-in: Type "Outlook-TypeScript-SSO".
Choose which office client application you want to target: Select "Outlook".
All the necessary files will be created for you.


package.json file

Change to that folder.

cd Outlook-TypeScript-SSO 

Open this folder in the current VS Code instance.

code -a . 

Open the Explorer pane (View > Explorer).
Click on the package.json file.

alt text
{ 
  "name": "office-addin-taskpane-sso",
  "version": "0.0.0",
  "private": true,
  "config": {
    "app_to_debug": "outlook",
    "app_type_to_debug": "desktop",
    "dev_server_port": 3000
  },
  "scripts": {
    "build": "webpack --mode production",
    "build:dev": "webpack --mode development",
    "configure-sso": "office-addin-sso configure manifest.xml",
    "dev-server": "webpack serve --mode development",
    "lint": "office-addin-lint check",
    "lint:fix": "office-addin-lint fix",
    "prettier": "office-addin-lint prettier",
    "sideload": "office-addin-debugging start manifest.xml",
    "start": "npm run build:dev && concurrently \"npm run start:server\"",
    "start:server": "office-addin-sso start manifest.xml",
    "stop": "office-addin-debugging stop manifest.xml",
    "validate": "office-addin-manifest validate manifest.xml",
    "watch": "webpack --watch --mode development"
  },
  "dependencies": {
    "core-js": "^3.9.1",
    "dotenv": "^8.2.0",
    "msal": "^1.3.2",
    "node-fetch": "^2.6.1",
    "office-addin-sso": "^1.2.8",
    "regenerator-runtime": "^0.13.7"
  },
  "devDependencies": {
    "@babel/core": "^7.13.10",
    "@babel/preset-typescript": "^7.13.0",
    "@types/jquery": "^3.3.31",
    "@types/office-js": "^1.0.180",
    "@types/office-runtime": "^1.0.17",
    "acorn": "^8.5.0",
    "babel-loader": "^8.2.2",
    "buffer": "^6.0.3",
    "concurrently": "^6.3.0",
    "copy-webpack-plugin": "^9.0.1",
    "eslint": "^7.32.0",
    "eslint-plugin-office-addins": "^2.0.0",
    "file-loader": "^6.2.0",
    "html-loader": "^2.1.2",
    "html-webpack-plugin": "^5.3.2",
    "office-addin-cli": "^1.3.5",
    "office-addin-debugging": "^4.3.8",
    "office-addin-dev-certs": "^1.7.7",
    "office-addin-lint": "^2.0.0",
    "office-addin-manifest": "^1.7.7",
    "office-addin-prettier-config": "^1.1.4",
    "https-browserify": "^1.0.0",
    "source-map-loader": "^3.0.0",
    "stream-http": "^3.2.0",
    "ts-loader": "^9.2.5",
    "typescript": "^4.3.5",
    "url": "0.11.0",
    "webpack": "^5.50.0",
    "webpack-cli": "^4.8.0",
    "webpack-dev-server": "4.7.3"
  },
  "prettier": "office-addin-prettier-config",
  "browserslist": [
    "ie 11"
  ]
}

manifest.xml

Click on the manifest.xml file.

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> 
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
           xsi:type="MailApp">
  <Id>a44efa3c-469c-45f3-8a93-31b5677a20b6</Id>
  <Version>1.0.0.0</Version>
  <ProviderName>Contoso</ProviderName>
  <DefaultLocale>en-US</DefaultLocale>
  <DisplayName DefaultValue="Outlook-TypeScript-SSO"/>
  <Description DefaultValue="An add-in that shows how to use SSO, and to fallback to interactive login when SSO is not available."/>
  <IconUrl DefaultValue="https://localhost:{PORT}/assets/icon-64.png"/>
  <HighResolutionIconUrl DefaultValue="https://localhost:{PORT}/assets/icon-128.png"/>
  <SupportUrl DefaultValue="https://www.contoso.com/help"/>
  <AppDomains>
    <AppDomain>https://www.contoso.com</AppDomain>
  </AppDomains>
  <Hosts>
    <Host Name="Mailbox"/>
  </Hosts>
  <Requirements>
    <Sets>
      <Set Name="MailBox" MinVersion="1.1"/>
    </Sets>
  </Requirements>
  <FormSettings>
    <Form xsi:type="ItemRead">
      <DesktopSettings>
        <SourceLocation DefaultValue="https://localhost:{PORT}/taskpane.html"/>
        <RequestedHeight>250</RequestedHeight>
      </DesktopSettings>
    </Form>
  </FormSettings>
  <Permissions>ReadWriteItem</Permissions>
  <Rule xsi:type="RuleCollection" Mode="Or">
    <Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
    <Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Read"/>
  </Rule>
  <VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
    <VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
      <Hosts>
        <Host xsi:type="MailHost">
          <DesktopFormFactor>
            <ExtensionPoint xsi:type="MessageComposeCommandSurface">
              <OfficeTab id="TabHome">
                <Group id="CommandsGroup">
                  <Label resid="CommandsGroup.Label"/>
                  <Control xsi:type="Button" id="TaskpaneButton">
                    <Label resid="TaskpaneButton.Label"/>
                    <Supertip>
                      <Title resid="TaskpaneButton.Label"/>
                      <Description resid="TaskpaneButton.Tooltip"/>
                    </Supertip>
                    <Icon>
                      <bt:Image size="16" resid="Icon.16x16"/>
                      <bt:Image size="32" resid="Icon.32x32"/>
                      <bt:Image size="80" resid="Icon.80x80"/>
                    </Icon>
                    <Action xsi:type="ShowTaskpane">
                      <SourceLocation resid="Taskpane.Url"/>
                    </Action>
                  </Control>
                </Group>
              </OfficeTab>
            </ExtensionPoint>
          </DesktopFormFactor>
        </Host>
      </Hosts>
      <Resources>
        <bt:Images>
          <bt:Image id="Icon.16x16" DefaultValue="https://localhost:{PORT}/assets/icon-16.png"/>
          <bt:Image id="Icon.32x32" DefaultValue="https://localhost:{PORT}/assets/icon-32.png"/>
          <bt:Image id="Icon.80x80" DefaultValue="https://localhost:{PORT}/assets/icon-80.png"/>
        </bt:Images>
        <bt:Urls>
          <bt:Url id="Commands.Url" DefaultValue="https://localhost:{PORT}/commands.html"/>
          <bt:Url id="Taskpane.Url" DefaultValue="https://localhost:{PORT}/taskpane.html"/>
        </bt:Urls>
        <bt:ShortStrings>
          <bt:String id="GetStarted.Title" DefaultValue="Get started with your sample add-in!"/>
          <bt:String id="CommandsGroup.Label" DefaultValue="Commands Group"/>
          <bt:String id="TaskpaneButton.Label" DefaultValue="Show Taskpane"/>
        </bt:ShortStrings>
        <bt:LongStrings>
          <bt:String id="GetStarted.Description" DefaultValue="Your sample add-in loaded succesfully. Go to the HOME tab and click the 'Show Taskpane' button to get started."/>
          <bt:String id="TaskpaneButton.Tooltip" DefaultValue="Click to Show a Taskpane"/>
        </bt:LongStrings>
      </Resources>
      <WebApplicationInfo>
        <Id>{application GUID here}</Id>
        <Resource>api://localhost:{PORT}/{application GUID here}</Resource>
        <Scopes>
          <Scope>User.Read</Scope>
          <Scope>profile</Scope>
        </Scopes>
      </WebApplicationInfo>
    </VersionOverrides>
  </VersionOverrides>
</OfficeApp>

webpack.config.js

Click on the webpack.config.js file.

/* eslint-disable no-undef */ 
const CopyWebpackPlugin = require("copy-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const urlDev = "https://localhost:3000/";
const urlProd = "https://www.contoso.com/";

module.exports = async (env, options) => {
  const dev = options.mode === "development";
  const buildType = dev ? "dev" : "prod";
  const config = {
    devtool: "source-map",
    entry: {
      polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
      taskpane: "./src/taskpane/taskpane.ts",
      commands: "./src/commands/commands.ts",
      fallbackauthdialog: "./src/helpers/fallbackauthdialog.ts",
    },
    output: {
      devtoolModuleFilenameTemplate: "webpack:///[resource-path]?[loaders]",
      clean: true,
    },
    resolve: {
      extensions: [".ts", ".tsx", ".html", ".js"],
      fallback: {
        buffer: require.resolve("buffer/"),
        http: require.resolve("stream-http"),
        https: require.resolve("https-browserify"),
        url: require.resolve("url/"),
      },
    },
    module: {
      rules: [
        {
          test: /\.ts$/,
          exclude: /node_modules/,
          use: {
            loader: "babel-loader",
            options: {
              presets: ["@babel/preset-typescript"],
            },
          },
        },
        {
          test: /\.tsx?$/,
          exclude: /node_modules/,
          use: "ts-loader",
        },
        {
          test: /\.html$/,
          exclude: /node_modules/,
          use: "html-loader",
        },
        {
          test: /\.(png|jpg|jpeg|gif|ico)$/,
          type: "asset/resource",
          generator: {
            filename: "assets/[name][ext][query]",
          },
        },
      ],
    },
    plugins: [
      new HtmlWebpackPlugin({
        filename: "taskpane.html",
        template: "./src/taskpane/taskpane.html",
        chunks: ["polyfill", "taskpane"],
      }),
      new HtmlWebpackPlugin({
        filename: "commands.html",
        template: "./src/commands/commands.html",
        chunks: ["polyfill", "commands"],
      }),
      new HtmlWebpackPlugin({
        filename: "fallbackauthdialog.html",
        template: "./src/helpers/fallbackauthdialog.html",
        chunks: ["polyfill", "fallbackauthdialog"],
      }),
      new CopyWebpackPlugin({
        patterns: [
          {
            from: "assets/*",
            to: "assets/[name][ext][query]",
          },
          {
            from: "manifest*.xml",
            to: "[name]." + buildType + "[ext]",
            transform(content) {
              if (dev) {
                return content;
              } else {
                return content.toString().replace(new RegExp(urlDev, "g"), urlProd);
              }
            },
          },
        ],
      }),
    ],
  };
  return config;
};

Other Source Files

The other project files are displayed on this page for reference.


SSO App Registration

To work with SSO you need to register your Office Add-in with the Microsoft identity platform.
link - learn.microsoft.com/en-us/office/dev/add-ins/develop/register-sso-add-in-aad-v2
Sign in to the Azure Active Directory Admin Center and create a new app registration.
Choose "Azure Active Directory"
Choose "App Registrations"
Add a new App Registration.
Name: "Outlook-TypeScript-SSO"
Choose: "Accounts in this organizational directory only (Single tenant)"
Copy and save the values for the Application (client) ID and the Directory (tenant) ID..
Add the following Client Secret: "MyClientSecret"
Add the following Application ID URI: "https://yourdomain.com/<<Application (client) ID>>
Check the API permissions: "User.Read"


Edit manifest.xml

Change the Unique GUID to a new Unique Id (visit www.guidgen.com).

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> 
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
           xsi:type="MailApp">
  <Id> Unique GUID </Id>

Change the DefaultValue for the IconUrl and HighResolutionIconUrl.

  <IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png" /> 
  <HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png" />

Change the AppDomain to "AppDomain1"

  <AppDomains> 
    <AppDomain>AppDomain1</AppDomain>
  </AppDomains>

Change the SourceLocation.

  <FormSettings> 
    <Form xsi:type="ItemRead">
      <DesktopSettings>
        <SourceLocation DefaultValue="https://localhost:3000/taskpane.html" />
        <RequestedHeight>250</RequestedHeight>
      </DesktopSettings>
    </Form>
  </FormSettings>

Insert the Runtimes tag.

  <Runtimes> 
    <Runtime resid="WebViewRuntime.Url">
      <Override type="javascript" resid="JSRuntime.Url"/>
    </Runtime>
  </Runtimes>

Insert the LaunchEvent extension point.

    <ExtensionPoint xsi:type="LaunchEvent"> 
      <LaunchEvents>
        <LaunchEvent Type="OnNewMessageCompose" FunctionName="onNewMessageComposeHandler"/>
      </LaunchEvents>
      <SourceLocation resid="WebViewRuntime.Url"/>
    </ExtensionPoint>

Change the DefaultValue for the images.

      <Resources> 
        <bt:Images>
          <bt:Image id="Icon.16x16" DefaultValue="https://img.icons8.com/ultraviolet/2x/processor.png" />
          <bt:Image id="Icon.32x32" DefaultValue="https://img.icons8.com/ultraviolet/2x/processor.png" />
          <bt:Image id="Icon.80x80" DefaultValue="https://img.icons8.com/ultraviolet/2x/processor.png" />
        </bt:Images>

Change the DefaultValue for the URLs.

        <bt:Urls> 
          <bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html" />
          <bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html" />
        </bt:Urls>

Insert 2 more Urls.

        <bt:Urls> 
          <bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
          <bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
          <bt:Url id="WebViewRuntime.Url" DefaultValue="https://localhost:3000/commands.html" />
          <bt:Url id="JSRuntime.Url" DefaultValue="https://localhost:3000/commands.js" />
        </bt:Urls>

Insert the "Application (client) ID" into the WebApplicationInfo tag
Add it to the ID tag.
Add it to the Resource tag.

      <WebApplicationInfo> 
        <Id> Application Client ID </Id>
        <Resource> api://bettersolutions.com/Application Client ID </Resource>
        <Scopes>
          <Scope>User.Read</Scope>
          <Scope>profile</Scope>
        </Scopes>
      </WebApplicationInfo>
    </VersionOverrides>
  </VersionOverrides>
</OfficeApp>

Edit .ENV

Insert the "Application (client) ID".
Insert the Port number.

CLIENT_ID=Application Client ID 
GRAPH_URL_SEGMENT=/me
NODE_ENV=development
PORT=3000
QUERY_PARAM_SEGMENT=
SCOPE=User.Read

Edit commands.ts

Add an event handler for onNewMessageComposeHandler

import { log, logObject } from "../helpers/debug"; 
import { getGraphAccessToken } from "../helpers/ssoauthhelper";
import { fetchDataAndInsertSignature } from "../shared/signature";

Office.onReady(() => {
});

export function action(event: Office.AddinCommands.Event) {
  const message: Office.NotificationMessageDetails = {
    type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
    message: "Performed action.",
    icon: "Icon.80x80",
    persistent: true,
  };

  Office.context.mailbox.item.notificationMessages.replaceAsync("action", message);

  event.completed();
}

async function onNewMessageComposeHandler(event) {
  log("onMessageComposeHandler");

  try {
    const accessToken = await getGraphAccessToken();
    await fetchDataAndInsertSignature(accessToken);
  } catch (error) {
    log("error");
    logObject(error);
  }
  event.completed();
}

function getGlobal() {
  return typeof self !== "undefined"
    ? self
    : typeof window !== "undefined"
    ? window
    : typeof global !== "undefined"
    ? global
    : undefined;
}

const g = getGlobal() as any;

g.action;
g.onNewMessageComposeHandler;

Office.actions.associate("onNewMessageComposeHandler", onNewMessageComposeHandler);

Create debug.ts

Create a TypeScript file in the helpers folder.

export const logObject = (obj: Record<string, unknown>): void => { 
  const props = Object.getOwnPropertyNames(obj);
  const objStringified = props.reduce((acc, prop) => {
    acc += `${prop}: ${String(obj[prop])}\n`;
    return acc;
  }, "");

  log(objStringified);
};

export function log(text: string) {
  console.log(`[outlook-cors-sample] ${text}`);
}

Edit ssoauthhelper.ts

import * as sso from "office-addin-sso"; 
import { log } from "./debug";
import { dialogFallback } from "./fallbackauthhelper";
import { callApi } from "./xhr";
let retryGetAccessToken = 0;

export async function getGraphAccessToken(): Promise<string> {
  try {
    log("getAccessToken");

    let bootstrapToken: string = await OfficeRuntime.auth.getAccessToken({ allowSignInPrompt: true });

    log("getGraphToken");
    let exchangeResponse: any = await getGraphToken(bootstrapToken);
    if (exchangeResponse.claims) {
      // Microsoft Graph requires an additional form of authentication. Have the Office host
      // get a new token using the Claims string, which tells AAD to prompt the user for all
      // required forms of authentication.
      let mfaBootstrapToken: string = await OfficeRuntime.auth.getAccessToken({
        authChallenge: exchangeResponse.claims,
      });
      exchangeResponse = await getGraphToken(mfaBootstrapToken);
    }

    if (exchangeResponse.error) {
      // AAD errors are returned to the client with HTTP code 200, so they do not trigger
      // the catch block below.
      handleAADErrors(exchangeResponse);
    } else {
      // makeGraphApiCall makes an AJAX call to the MS Graph endpoint. Errors are caught
      // in the .fail callback of that call
      return exchangeResponse.access_token;
    }
  } catch (exception) {
    // if handleClientSideErrors returns true then we will try to authenticate via the fallback
    // dialog rather than simply throw and error
    if (exception.code) {
      if (sso.handleClientSideErrors(exception)) {
        dialogFallback();
      }
    } else {
      throw exception;
    }
  }
}

async function getGraphToken(bootstrapToken: string) {
  const jsonResponse = await callApi(`https://localhost:3000/auth`, {
    method: "GET",
    headers: { Authorization: `Bearer ${bootstrapToken}` },
  });

  return JSON.parse(jsonResponse);
}

function handleAADErrors(exchangeResponse: any): void {
  // On rare occasions the bootstrap token is unexpired when Office validates it,
  // but expires by the time it is sent to AAD for exchange. AAD will respond
  // with "The provided value for the 'assertion' is not valid. The assertion has expired."
  // Retry the call of getAccessToken (no more than once). This time Office will return a
  // new unexpired bootstrap token.

  if (exchangeResponse.error_description.indexOf("AADSTS500133") !== -1 && retryGetAccessToken <= 0) {
    retryGetAccessToken++;
    getGraphAccessToken();
  } else {
    dialogFallback();
  }
}

Create xhr.ts

Create a TypeScript file in the helpers folder.

export function callApi(url: string, requestInit?: RequestInit): Promise<string> { 
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();

    xhr.onload = function () {
      resolve(xhr.responseText);
    };

    xhr.onerror = function () {
      const error = {
        status: xhr.status,
        statusText: xhr.statusText,
        responseText: xhr.responseText,
      };
      reject(error);
    };

    const method = requestInit?.method ?? "GET";

    const headers = requestInit?.headers as Record<string, string> | undefined;

    const body = requestInit?.body as string | undefined;

    xhr.open(method, url, true);

    if (headers) {
      for (const header of Object.keys(headers)) {
        const val = headers[header];
        xhr.setRequestHeader(header, val);
      }
    }

    xhr.send(body);
  });
}

Edit fallbackauthhelper.ts

Add the following line at the top.

import { fetchDataAndInsertSignature } from "../shared/signature"; 

Create signature.ts

Create a new folder called "shared".
Create a TypeScript file in this folder called "signature.ts".

import { log } from "../helpers/debug"; 
import { callApi } from "../helpers/xhr";

export async function fetchDataAndInsertSignature(accessToken: string) {
  log("makeUserGraphApiCall");
  const userResponse = await makeUserGraphApiCall(accessToken);

  log("makeOrganizationGraphApiCall");
  const orgResponse = await makeOrganizationGraphApiCall(accessToken);

  const signatureData: SignatureData = {
    displayName: userResponse["displayName"] ?? "",
    mail: userResponse["mail"] ?? "",
    jobTitle: userResponse["jobTitle"] ?? "",
    department: userResponse["department"] ?? "",
    company: orgResponse.value[0]["displayName"] ?? "",
  };
  await insertSignature(signatureData);
}

async function makeUserGraphApiCall(accessToken: string) {
  const jsonResponse = await callApi(
    "https://graph.microsoft.com/v1.0/me?$select=displayName,mail,jobTitle,department",
    {
      method: "GET",
      headers: { Authorization: `Bearer ${accessToken}` },
    }
  );

  return JSON.parse(jsonResponse);
}

async function makeOrganizationGraphApiCall(accessToken: string) {
  const jsonResponse = await callApi("https://graph.microsoft.com/v1.0/organization", {
    method: "GET",
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  return JSON.parse(jsonResponse);
}

function insertSignature(signatureData: SignatureData): Promise<void> {
  log("insertSignature");

  const userSignature: string = `
    <div style="font-family: Bierstadt, Calibri">
        <div style="font-size: 16px; font-weight: bold">${signatureData.displayName}</div>
        <div>${signatureData.jobTitle}</div>
        <div>${signatureData.department}</div>
        <div>${signatureData.mail}</div>
        <p style="font-size: 16px">${signatureData.company}</p>
    </div>`;

  return new Promise((resolve, reject) => {
    Office.context.mailbox.item.body.setSignatureAsync(
      userSignature,
      { coercionType: Office.CoercionType.Html },
      (asyncResult) => {
        if (asyncResult.status === Office.AsyncResultStatus.Succeeded) {
          resolve(asyncResult.value);
        } else {
          reject(asyncResult.error);
        }
      }
    );
  });
}

type SignatureData = {
  displayName: string;
  mail: string;
  jobTitle: string;
  department: string;
  company: string;
};

Customise the Add-in Further

link - learn.microsoft.com/en-us/office/dev/add-ins/develop/sso-in-office-add-ins
link - learn.microsoft.com/en-us/office/dev/add-ins/quickstarts/sso-quickstart-customize


© 2022 Better Solutions Limited. All Rights Reserved. © 2022 Better Solutions Limited TopPrevNext