A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph V

A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph V

The action based variant

I recently started to deep-dive in Microsoft Teams development and I believe it will become a big thing especially around “integration”. But what’s one of the main points when it comes to integrate systems, it’s all about authentication. This is missing in the very basic starter documentation and that’s for reasons. Outside the SPFx world it’s not one of the easiest tasks.

My use case scenario is:
Assume you have some documents out there that need a review from time to time and those that are “due” you wanna share inside Microsoft Teams. So a user can select inside his channel from all retrieved due documents to post them as a card into the current channel.

In the previous parts we were simply showing the results from our query on documents needing a review in a simple and less customizable way:

Search Based Messaging Extension to review documents

But there are other options when entering a messaging extension and those are much more customizable. In an action based variant information could be retrieved by UI created by an Adaptive Card, static properties or a Task Module. To reuse the existing scenario, assume we are not satisfied with the easy rendering of above search result and want to do it in a more customized way to present the retrieved documents. A custom task modul can be the way to go.

First we need to setup another project with the yeoman teams generator which could be done with the following selections:

Yeoman Teams generator creating a Messaging Extension (action based)

As in part I of this series a bot channel needs to be created but this time without a custom connection.

The app registration is also slightly different. Against the search based scenario now the authentication takes place in the frontend. That is the same scenario than establishing an SSO inside a Teams Tab followed by an on-behalf flow request for an access token. Details can be found in following two resources:

As in the first part an app registration with a client secret and the necessary permissions needs to be created. But on top an api needs to be exposed the following way:

Expose Api for Teams SSO

The essential thing here is the Application ID Uri which is composed of the given ngrok url (or later a production based web service url, but currently NO standard *.azurewebsites.net supported!) followed by the app id.
Next is a scope “access_as_user” to be consented by admin and users with corresponding text messages. And finally two client applications added, that is the Teams app and the web based application.

Once the app registration is done, it needs to be added to the app. First in the app manifest for the SSO operation:

"webApplicationInfo": {
    "id": "{{GRAPH_APP_ID}}",
    "resource": "api://{{HOSTNAME}}/{{GRAPH_APP_ID}}"
}

And next in the .env file

GRAPH_APP_ID=82103d2b-6659-454e-923f-e921b436faee
GRAPH_APP_SECRET=.iMylSCL~X6-D20buw4P8GG.5wu.u_3xI.

The option to refer to the .env file when building the manifest is used here so no need to enter the app id twice respectively change a temp. ngrok url all the time in several positions.

Now coding can start. First the SSO needs to be implemented which will be done in the frontend part. This is located at src\app\scrips\<extensionName>\<extensionName>Action.tsx

public componentWillMount() {
    this.updateTheme(this.getQueryVariable("theme"));

    microsoftTeams.initialize(() => {
      microsoftTeams.registerOnThemeChangeHandler(this.updateTheme);
      microsoftTeams.getContext((context) => {
        this.setState({
          entityId: context.entityId
        });
        this.updateTheme(context.theme);
        microsoftTeams.authentication.getAuthToken({
          successCallback: (token: string) => {
            const decoded: { [key: string]: any; } = jwt.decode(token) as { [key: string]: any; };
            this.setState({ name: decoded!.name,
                            ssoToken: token });
            this.loadFiles();
            microsoftTeams.appInitialization.notifySuccess();
          },
          failureCallback: (message: string) => {
            this.setState({ error: message });
            microsoftTeams.appInitialization.notifyFailure({
                reason: microsoftTeams.appInitialization.FailedReason.AuthFailed,
                message
            });
          },
          resources: [`api://${process.env.HOSTNAME}/${process.env.GRAPH_APP_ID}`]
        });
      });
    });
  }

In the Teams initialize function an authToken is retrieved. If this is successful the given token (an ID token only!) can be stored in the state and the loadFiles() function can be called. To identify the configured app, the resource api is given as well.
Next the files can be retrieved:

private loadFiles = () => {
    if (this.state.ssoToken) {
      Axios.get(`https://${process.env.HOSTNAME}/api/files`, {
                      responseType: "json",
                      headers: {
                          Authorization: `Bearer ${this.state.ssoToken}`
                      }
          }).then(result => {
            let docs: IDocument[] = [];
            result.data.forEach(d => {
              docs.push({ name: d.name, id: d.id, description: d.description, author: d.author, nextReview: new Date(d.nextReview), modified: new Date(d.modified), url: d.url });
            });
            this.setState({
                documents: docs
            });
          })
          .catch((error) => {
              console.log(error);
          });
    }
  

Here the ssoToken is used for authentication and a rest call is made against the backend of the same app. Once the documents are returned they are transformed and set to the state. But what happens meanwhile in the backend?

export const graphRouter = (options: any): express.Router =>  {
  const router = express.Router();

  // Set up the Bearer Strategy
  const bearerStrategy = new BearerStrategy({
      identityMetadata: "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
      clientID: process.env.GRAPH_APP_ID as string,
      audience: `api://${process.env.HOSTNAME}/${process.env.GRAPH_APP_ID}`,
      loggingLevel: "info",
      loggingNoPII: false,
      validateIssuer: false,
      passReqToCallback: false
  } as IBearerStrategyOption,
      (token: ITokenPayload, done: VerifyCallback) => {
          done(null, { tid: token.tid, name: token.name, upn: token.upn }, token);
      }
  );
  const pass = new passport.Passport();
  router.use(pass.initialize());
  pass.use(bearerStrategy);

  const exchangeForToken = (tid: string, token: string, scopes: string[]): Promise<string> => {
    return new Promise((resolve, reject) => {
        const url = `https://login.microsoftonline.com/${tid}/oauth2/v2.0/token`;
        const params = {
            client_id: process.env.GRAPH_APP_ID,
            client_secret: process.env.GRAPH_APP_SECRET,
            grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
            assertion: token,
            requested_token_use: "on_behalf_of",
            scope: scopes.join(" ")
        };

        Axios.post(url,
            qs.stringify(params), {
            headers: {
                "Accept": "application/json",
                "Content-Type": "application/x-www-form-urlencoded"
            }
        }).then(result => {
            if (result.status !== 200) {
                reject(result);
                log(result.statusText);
            } else {
                resolve(result.data.access_token);
            }
        }).catch(err => {
            // error code 400 likely means you have not done an admin consent on the app
            log(err.response.data);
            reject(err);
        });
    });
  };
router.get(
    "/files",
    pass.authenticate("oauth-bearer", { session: false }),
    async (req: express.Request, res: express.Response, next: express.NextFunction) => {
        const user: any = req.user;
        const today = new Date().toISOString().substr(0,10);
        const siteID = process.env.SITE_ID;
        const listID = process.env.LIST_ID;
        const requestUrl: string = `https://graph.microsoft.com/v1.0/sites/${siteID}/lists/${listID}/items?$filter=fields/NextReview lt '${today}'&expand=fields`;
        
        try {
            const accessToken = await exchangeForToken(user.tid,
                req.header("Authorization")!.replace("Bearer ", "") as string,
                ["https://graph.microsoft.com/user.read"]);
            log(accessToken);
            Axios.get(requestUrl, {
                headers: {          
                    Authorization: `Bearer ${accessToken}`
                }})
                .then(response => {
                  let docs: IDocument[] = [];
                  console.log(response.data.value);
                  response.data.value.forEach(element => {
                    docs.push({
                      name: element.fields.FileLeafRef,
                      description: element.fields.Description0,
                      author: element.createdBy.user.displayName,
                      url: element.webUrl,
                      id: element.id,
                      modified: new Date(element.lastModifiedDateTime),
                      nextReview: new Date(element.fields.NextReview)
                    });
                  });                                            
                res.json(docs);
            }).catch(err => {
                res.status(500).send(err);
            });
        } catch (err) {
            if (err.status) {
                res.status(err.status).send(err.message);
            } else {
                res.status(500).send(err);
            }
        }
    });

  return router;
}

For the backend an own graphRouter is registered in server.ts Once the file method is called at first the bearer authentication token, our ssoToken from the frontend is grabbed and with the exchangeForToken function transformed into an access token with the so called ‘on behalf flow’. Only that access token is able to use the needed permissions and successfully enable the retrieval of site objects such as documents. Having that access token, nothing special anymore, a request for Microsoft Graph is built and executed. The retrieved documents are returned to the frontend.

There the result can be rendered which is established by some components from fluentui/react-northstar which is included in the yeomen teams generator and seems to be going merged with the new fluentui (former Office UI Fabric). Let’s see how it goes but give it a try here to demonstrate the usage of a customized rendering.

import { Provider, Flex, Header, List, RedbangIcon } from "@fluentui/react-northstar";

public render() {
    let listItems: any[] = [];
    if (this.state.documents) {
      this.state.documents.forEach((doc) => {
        let urgentLimit = new Date();
        urgentLimit.setDate(urgentLimit.getDate() - 7);
        const urgent: boolean = doc.nextReview < urgentLimit;
        listItems.push({
            key: doc.id,
            header: doc.name,
            media: urgent ? <RedbangIcon /> : null,
            important: urgent,
            headerMedia: doc.nextReview.toLocaleDateString(),
            content: doc.description
        });
      });
    }
    return (
      <Provider theme={this.state.theme}>
        <Flex fill={true} column styles={{
            padding: ".8rem 0 .8rem .5rem"
        }}>
          <Flex.Item>
            <div>
              <Header content="Documents for review: " />
              <List selectable
                      selectedIndex={this.state.selectedListItem}
                      onSelectedIndexChange={this.listItemSelected}
                      items={listItems}
                      />
            </div>
          </Flex.Item>
        </Flex>
      </Provider>
    );
  }

The items are rendered here in a List and marked ‘special’ with a ! in case they are 7 days overdue. This will look like this:

The task module showing list of documents for review

Once a document is picked this will be returned as an AdaptiveCard to the message with the listItemSelected function:

private listItemSelected = (e, newProps) => {
    const selectedDoc = this.state.documents.filter(doc => doc.id === newProps.items[newProps.selectedIndex].key)[0];
    microsoftTeams.tasks.submitTask({
        doc: selectedDoc
    });
    this.setState({
      selectedListItem: newProps.selectedIndex
    });
  
The result – A document for review as an adaptive card

The main aspects are covered now. Compared to the search based variant the main logic takes place in the frontend now and except some similar things such as configuration settings in the main backend component nothing special happens anymore:

export default class DocReviewActionExtensionMessageExtension implements IMessagingExtensionMiddlewareProcessor {

    public async onFetchTask(context: TurnContext, value: MessagingExtensionQuery): Promise<MessagingExtensionResult | TaskModuleContinueResponse> {
     
      return Promise.resolve<TaskModuleContinueResponse>({
        type: "continue",
        value: {
            title: "Input form",
            url: `https://${process.env.HOSTNAME}/docReviewActionExtensionMessageExtension/action.html`
        }
      });
    }

    public async onSubmitAction(context: TurnContext, value: TaskModuleRequest): Promise<MessagingExtensionResult> {
      const card = CardFactory.adaptiveCard(
      {
        type: "AdaptiveCard",
        body: [
          {
            type: "ColumnSet",
            columns: [
                 ....
            ]
          }
        ],
        $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
        version: "1.0"
      });
      return Promise.resolve({
          type: "result",
          attachmentLayout: "list",
          attachments: [card]
      } as MessagingExtensionResult);
    }

The entry point is the onFetchTask function here. Except the check for configuration (already known from part IV and not different here) it only returns the frontend task module covered above. And the submitAction does nothing else than transforming the picked document object to an adaptive card and give back.

But what if there also is some backend action? As known from part II the capability to mark a document for review was already implemented. So in this case the need for same backend authentication as in part II would be necessary as a potential action click is totally decoupled from the shown task module.

Update: You can also use another task module and handle auth/access to Graph in the frontend as shown above. In my SPFx variant I am doing exactly that.

For a full reference of this action based messaging extension please refer to the github repository. As an outlook to the next option: Since SPFx 1.11 you can also use SharePoint Framework for the task module, that is the frontend component shown here. This further simplifies the authentication mechanism to access Microsoft Graph or other 3rd party APIs. My intention is to adapt this example to that option as well quite soon. Stay tuned. I meanwhile added this as an inofficial part to this series. Inofficial because one main aspect in this series, the authentication, does not really take place there anymore. But the SPFx approach also has downsides why this post still has it’s validity.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.

6 thoughts on “A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph V

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s