Office Add-ins: Get consent for several resources using MSAL

5
(5)

In this blog post we will see how to setup MSAL to get consent for several resources in an office add-in to get access to Microsoft Graph, SharePoint and a secured Azure functions.

If you are looking for the source code, you can find it here ๐Ÿ™‚

Create a word add-in project

First we need to install Yeoman (as a prerequisites) and generator-office to create an office add-ins:

npm install -g yo
npm install -g yo generator-office

Then run following command and create a word add-in project:

Register an app registration in Azure

Navigate to Azure Portal – App registration page with your admin credentials and select New registration, set the values as follows:

You can change the name to your preferred name, copy the Application (client) ID and the Directory (tenant) ID (you will use both of them in later procedures).

Select Expose an API under Manage. Select the Set link to generate the application ID URI and update it to the form of api://$FQDN-WITHOUT-PROTOCOL$/$App ID GUID$.

Select the Add a scope button. Set the values as follows:

Now we need to identify the applications we want to authorize to our add-in’s web application. In the Authorized client applications, select Add a client application button for each of the following IDs:

  • d3590ed6-52b3-4102-aeff-aad2292ab01c (Microsoft Office)
  • ea5a67f6-b6f3-4338-b240-c655ddc3cc8e (Microsoft Office)
  • 57fb890c-0dab-4253-a5e0-7188c88b2bb4 (Office on the web)
  • bc59ab01-8403-45c6-8796-ac3ef710b3e3 (Outlook on the web, add only if you want to develop an outlook add-in)

Make sure you check the box for api://$FQDN-WITHOUT-PROTOCOL$/$App ID GUID$/access_as_user.

The expose an API page should look like this now:

Select Authentication under Manage. Select Add a platform button, Select Web and set the values as follows:

Select API permissions and select Add a permission, Choose Microsoft Graph and then choose Delegated permissions.

Find following permissions and select them:

Then select Add a permission again, choose SharePoint and add following permissions (you can change the permissions base on your application needs).

I created a really simple Azure Function which receives a value as name and return a simple hello message, here is the code:

#r "Newtonsoft.Json"

using System.Net;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;

public static async Task<IActionResult> Run(HttpRequest req, ILogger log)
{
    log.LogInformation("C# HTTP trigger function processed a request.");

    string name = req.Query["name"];

    string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
    dynamic data = JsonConvert.DeserializeObject(requestBody);
    name = name ?? data?.name;

    string responseMessage = string.IsNullOrEmpty(name)
        ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
                : $"Hello, {name}. This HTTP triggered function executed successfully.";

            return new OkObjectResult(responseMessage);
}

And also I enabled the app service authentication with following settings:

Select Add a permission, choose APIs my organization uses, now you can see a list of web apps, choose your Azure function, and add the permission.

Now we have 3 different resources in our app registration:

Install dependencies

Here are the libraries we need to install for this project:

npm i antd @pnp/common @pnp/logging @pnp/nodejs @pnp/nodejs-commonjs @pnp/odata @pnp/sp axios @microsoft/microsoft-graph-client @microsoft/microsoft-graph-types msal

Interface

Each add-in project can contains commands, task panes, dialog boxes and content add-ins. For this project we are going to have a task pane which users can see their profile information such as their role, address, phones and location, they can search for items in SharePoint and execute an HTTP triggered function. Here is how our interface would look like:

If you open the project in an editor like Visual Studio Code, under src folder you can find two folders for building your elements which are commands and taskpane, expand taskpane folder and components, then open the app.tsx file and replace the code with:

import * as React from "react";
import Progress from "./Progress";
import { Button, Divider, Row, Descriptions, List } from "antd";
import "antd/dist/antd.css";
import { loginService } from "../../services/services";
import useObservable from "../../hooks/useObservable";
import { SharePointController, GraphController, AzureController } from "../../controllers";
import { Spin, Space } from "antd";
import { Alert } from "antd";
import Search from "antd/lib/input/Search";
import { User } from "@microsoft/microsoft-graph-types";

interface IAppProps {
  title: string;
  isOfficeInitialized: boolean;
}

const App: React.FC<IAppProps> = props => {
  const tokens = useObservable(loginService.tokens);
  const error = useObservable(loginService.errorMessage);
  const [userInfo, setUserInfo] = React.useState<User>(null);
  const [azureResponse, setAzureResponse] = React.useState("");
  const [searchResult, setSearchResult] = React.useState([]);
  const [loading, setLoading] = React.useState(false);

  const getUserDetails = async () => {
    const controller = new GraphController(tokens.graphToken);
    const user = await controller.getUserInformation();
    setUserInfo(user);
  };

  const searchDocuments = async value => {
    const spController = new SharePointController(tokens.sharePointToken);
    const result = await spController.searchDocuments(value);
    setSearchResult(result);
  };

  const callAzureFunction = async value => {
    const azureController = new AzureController(tokens.azureToken);
    const response = await azureController.callAzureFunction(value);
    setAzureResponse(response);
  };

  const logOut = () => {
    setLoading(false);
    loginService.logOut();
  };

  const logIn = () => {
    setLoading(true);
    loginService.getAccessToken();
  };

  const onClose = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
    console.log(e, "Error message closed!");
  };

  if (!props.isOfficeInitialized) {
    return (
      <Progress
        title={props.title}
        logo="assets/logo-filled.png"
        message="Please sideload your addin to see app body."
      />
    );
  }
  if (!tokens) {
    return (
      <div className="ms-welcome__main">
        {error && <Alert message="Error" description={error} type="error" closable onClose={onClose} />}
        {!loading && (
          <Row>
            <Button type="link" block onClick={logIn}>
              Log in
            </Button>
          </Row>
        )}
        {loading && (
          <Space size="middle">
            <Spin size="large" tip="Loading..." />
          </Space>
        )}
      </div>
    );
  }
  return (
    <div className="ms-welcome__main">
      <Row>
        <Button type="link" block onClick={logOut}>
          Log Out
        </Button>
      </Row>
      <Divider>Graph API</Divider>
      <Space direction="vertical">
        {!userInfo && (
          <Button type="primary" onClick={getUserDetails}>
            Get User Profile
          </Button>
        )}

        {userInfo && (
          <Descriptions>
            <Descriptions.Item label="First Name">{userInfo?.givenName}</Descriptions.Item>
            <Descriptions.Item label="Last Name">{userInfo?.surname}</Descriptions.Item>
            <Descriptions.Item label="Job Title">{userInfo?.jobTitle}</Descriptions.Item>
            <Descriptions.Item label="Department">{userInfo?.department}</Descriptions.Item>
            <Descriptions.Item label="Mobile">{userInfo?.mobilePhone}</Descriptions.Item>
            <Descriptions.Item label="Phone">{userInfo?.businessPhones[0]}</Descriptions.Item>
            <Descriptions.Item label="City">{userInfo?.city}</Descriptions.Item>
          </Descriptions>
        )}
      </Space>

      <Divider>SharePoint API</Divider>
      <Search
        placeholder="Search for documents in SharePoint"
        onSearch={value => searchDocuments(value)}
        enterButton
      />
      {searchResult.length > 0 && (
        <Row>
          <List
            itemLayout="horizontal"
            dataSource={searchResult}
            renderItem={item => (
              <List.Item>
                <a href={item.Path}>{item.Title}</a>
              </List.Item>
            )}
          />
        </Row>
      )}
      <Divider>Azure Function</Divider>
      <Search
        placeholder="Enter your name"
        enterButton="Get"
        size="middle"
        onSearch={value => callAzureFunction(value)}
      />
      <span className="azure-result-span">{azureResponse}</span>
    </div>
  );
};

export default App;

Get Access Tokens Using MSAL

Now we can focus on getting the access tokens process, first I’m going to add below files to the project:

  • Controllers
    • AzureController.ts
    • GraphController.ts
    • SharePointController.ts
  • Helpers
    • configuration.ts
    • login.ts, login.html
    • logout,ts, logout.html
    • logoutcomplete.html
    • msalhelper.ts
  • Services
    • LoginService.ts
    • Services.ts

Let’s start from the service class, open the loginservice.ts:

import { Observable } from "../hooks/observable";
import { loginToOffice365, logoutFromO365 } from "../helpers/msalHelper";
import IAuthToken from "../models/IAuthToken";

export default class LoginService {
  public readonly errorMessage = new Observable<string>("");
  public readonly tokens = new Observable<IAuthToken>(null);

  public setTokens(tokens: IAuthToken) {
    this.tokens.set(tokens);
    sessionStorage.setItem("cachedTokens",JSON.stringify(tokens));
  }

  public setErrorMessage(error: string) {
    this.errorMessage.set(error);
  }

  public async getAccessToken(): Promise<void> {
    const cachedTokens = localStorage.getItem("cachedTokens");
    if (cachedTokens) {
      this.tokens.set(JSON.parse(cachedTokens));
    } else {
      localStorage.setItem("loggedIn", "");
      loginToOffice365();
    }
  }

  public logOut(){
    logoutFromO365();
  }
}

The login service class provides two observable objects to return the access tokens and error message to the subscribers which is app.tsx in this sample but you can have multiple components subscribing to this service, so when a component request for the access tokens, this class first checks if there is cached object available to return (it can be session or local storage) otherwise it will open a pop-up window to go through the login process using MSAL library.

The configuration.ts file contains variables such as scopes for your resources, azure function url and MSAL configuration.

Now let’s explain the most important file in this project which is MSALHelper.ts:

import { loginService } from "../services/services";
import IAuthToken from "../models/IAuthToken";
import { AuthResponse } from "msal/lib-commonjs/AuthResponse";
import * as Msal from "msal";
import * as config from "./configuration";

let loginDialog: Office.Dialog;
let logoutDialog: Office.Dialog;
const url = location.protocol + "//" + location.hostname + (location.port ? ":" + location.port : "");

// This handler responds to the success or failure message that the pop-up dialog receives from the identity provider
// and access token provider.
async function processMessage(arg) {
  let messageFromDialog = JSON.parse(arg.message);

  if (messageFromDialog.status === "success") {
    // We now have a valid access token.
    loginDialog.close();
    const response: AuthResponse = messageFromDialog.result;
    const userAgentApp = new Msal.UserAgentApplication(config.MsalConfiguration);
    const spReqObj = {
      account: response.account,
      scopes: config.SharePointScope
    };
    const azureReqObj = {
      account: response.account,
      scopes: config.AzureScope
    };

    // Request SharePoint Access Token
    const spTokenResponse = await userAgentApp.acquireTokenSilent(spReqObj);

    // Request Azure Access Token
    const azureTokenResponse = await userAgentApp.acquireTokenSilent(azureReqObj);

    const tokens: IAuthToken = {
      graphToken: response.accessToken,
      sharePointToken: spTokenResponse.accessToken,
      azureToken: azureTokenResponse.accessToken
    };

    loginService.setTokens(tokens);
  } else {
    // Something went wrong with authentication or the authorization of the web application.
    loginDialog.close();
    loginService.setErrorMessage(JSON.stringify(messageFromDialog.error.toString()));
  }
}

// Use the Office dialog API to open a pop-up and display the sign-in page for the identity provider.
export const loginToOffice365 = () => {
  var fullUrl = url + "/login.html";

  // height and width are percentages of the size of the parent Office application, e.g., PowerPoint, Excel, Word, etc.
  Office.context.ui.displayDialogAsync(fullUrl, { height: 60, width: 30 }, function(result) {
    console.log("Dialog has initialized. Wiring up events");
    loginDialog = result.value;
    loginDialog.addEventHandler(Office.EventType.DialogMessageReceived, processMessage);
  });
};

export const logoutFromO365 = async () => {
  var fullUrl = url + "/logout.html";
  Office.context.ui.displayDialogAsync(fullUrl, { height: 40, width: 30 }, result => {
    if (result.status === Office.AsyncResultStatus.Failed) {
      loginService.setErrorMessage(`${result.error.code} ${result.error.message}`);
    } else {
      logoutDialog = result.value;
      logoutDialog.addEventHandler(Office.EventType.DialogMessageReceived, processLogoutMessage);
    }
  });

  const processLogoutMessage = () => {
    loginService.setTokens(null);
    localStorage.setItem("loggedIn","");
    logoutDialog.close();
  };
};

We use this helper to open a pop-up window to display the sign-in or sign-out page, the sign-out process is simple, it redirects user to the sign-out page and when they signed out, it closes the pop-up window and clean the local storage and tokens in the services class (processLogoutMessage method).

Sign-in process is a bit different, as the Microsoft Identity Platform doesn’t allow you to get a token for several resources at once, we can silently acquire the token from the token cache using acquireTokenSilent:

// Request SharePoint Access Token
const spTokenResponse = await userAgentApp.acquireTokenSilent(spReqObj);

// Request Azure Access Token
const azureTokenResponse = await userAgentApp.acquireTokenSilent(azureReqObj);

Now that we have the tokens, we can implement our controllers, let’s start from GraphController:

import { Client } from "@microsoft/microsoft-graph-client";
import * as MicrosoftGraph from "@microsoft/microsoft-graph-types";

export default class GraphController {
  private client: Client;
  constructor(token: string) {
    const options = {
      defaultVersion: "beta",
      debugLogging: true,      
      authProvider: done => {
        done(null, token);
      }
    };
    this.client = Client.init(options);
  }

  public getClient() {
    return this.client;
  }

  public async getUserInformation() {
    try {
      const userDetails: MicrosoftGraph.User = await this.client.api("/me").get();
      return userDetails;
    } catch (error) {
      throw error;
    }
  }

  public async getUserPhoto(){    
    const photo = await this.client.api("/me/photo/$value").options({encoding:null}).get();
    return Buffer.from(photo).toString('base64');
    
  }
}

As you can see in the constructor method I set the options to use beta version as it provides more APIs, but you can change it to V1.0, and most importantly we need to set the authProvider with the token we got from previous step. Now you can use Microsoft Graph Client library to consume graph api.

In SharePointController class, I setup PnP js in the constructor method:

import { sp } from "@pnp/sp";
import { Web } from "@pnp/sp/webs";
import { SearchResults } from "@pnp/sp/search";
import "@pnp/sp/lists";
import "@pnp/sp/folders";
import "@pnp/sp/search";
import "@pnp/sp/files";
import * as config from "../helpers/configuration";

const libraryExclusions: string[] = ["Form Templates", "Site Assets", "Style Library", "Teams Wiki Data"];

export default class SharePointController {
  constructor(token: string) {    
    sp.setup({
      sp: {
        baseUrl: `${config.SharePointUrl}`,
        headers: {
          Accept: "application/json;odata=verbose",
          Authorization: `Bearer ${token}`
        }
      }
    });
  }
  
  public async getSiteLibraries(siteUri:string){
    const web = Web(siteUri);
    var response = await web.lists.filter("BaseTemplate eq 101")();    
    var libraries = response.filter(library => libraryExclusions.indexOf(library.Title) === -1);
    return libraries;
  }

  public async getSiteTitle(siteUrl:string){
    const web = Web(siteUrl);
    var response = await web.select("Title")();
    return response.Title;
  }

  public async searchDocuments(query:string) {
    const queryText =`Title:'${query}*'`;    
    let content:SearchResults = await sp.search({
      SelectProperties:["Path","Title","DefaultEncodingURL"],
      Querytext:queryText,
      RowLimit:5
    });
    return content.PrimarySearchResults;
  }

}

As you can see I set the Authorization header with the token and you can have your own methods to do the search or whatever you need from SharePoint in your project.

For AzureController class I use Axios to make http requests:

import * as config from "../helpers/configuration";
import axios from "axios";

export default class AzureController {
  constructor(private token: string) {}

  public async callAzureFunction(name:string) {
    const body = {
      name
    };
    
    const response = await axios({
      url: `${config.AzureFunctionUri}`,
      method: "POST",
      data: JSON.stringify(body),
      headers: { Authorization: `Bearer ${this.token}`}
    });
    return response.data;
  }
}

Webpack configuration

There are some changes we need to make in the webpack.config.js, first we need to add two entry points for our login and logout files, this is how the entry object would look like:

entry: {
  login:"./src/helpers/login.ts",
  logout: './src/helpers/logout.ts',
  vendor: [
    'react',
    'react-dom',
    'core-js',
    'office-ui-fabric-react'
    ],
    taskpane: [
        'react-hot-loader/patch',
        './src/taskpane/index.tsx',
    ],
    commands: './src/commands/commands.ts'
}

And in plugins array we need to add these 3 objects:

new HtmlWebpackPlugin({
    filename: "login.html",
    template: "./src/helpers/login.html",
    chunks: ["polyfill", "login"]
}),
new HtmlWebpackPlugin({
    filename: "logout.html",
    template: "./src/helpers/logout.html",
    chunks: ["polyfill", "logout"]
}),
new HtmlWebpackPlugin({
    filename: "logoutcomplete.html",
    template: "./src/helpers/logoutcomplete.html",
    chunks: ["polyfill", "logoutcomplete"]
})

See it in action

Here is our word add-in accessing several resources in action:

Summary

What you have seen above is an Office add-in using MSAL to not only add the login and logout process to the project but also to get consent for several resources such as Microsoft Graph, SharePoint and a secured Azure function. I hope you enjoyed reading this post.

How useful was this post?

Click on a star to rate it!

Average rating 5 / 5. Vote count: 5

No votes so far! Be the first to rate this post.

2 Replies to “Office Add-ins: Get consent for several resources using MSAL”

Leave a Reply

Your email address will not be published. Required fields are marked *