Extend Teams apps to M365 with SSO the right way

Extend Teams apps to M365 with SSO the right way

Since Teams JS SDK v 2.0 and Teams manifest v 1.13 it is possible to extend Teams applications to several M365 products. This post intends to illustrate this with a personal app running in Microsoft Teams and Microsoft Outlook or Office. It uses single-sign-on (SSO) and renders slightly different in the various M365 products.

For the sample idea I am reusing a scenario I implemented long time ago in a SPFx solution intended to act as an outlook add-in. (The underlying SPFx technology unfortunately was a stillbirth) I want to select one of user’s recent emails and store them as a whole in either OneDrive or Teams. Same thing could be implemented in a messaging extension which is also available in developer preview in Microsoft Outlook now but unfortunately only in the compose message, not in the context of a received email, yet.

App in action inside Teams - Upload mail to selected Team (folder)​
App in action inside Teams – Upload mail to selected Team (folder)

As there are lots of Microsoft Graph features in it, all implemented based on single-sign-on (SSO) another focus of this article is to do it in a secure and best practice way.

Content

Setup with SSO

What’s needed for this scenario is a Teams personal tab application supporting single-sign-on(SSO). With the yo Teams generator it can be set up the following:

yo teams - Setup personal tab with SSO
yo teams – Setup personal tab with SSO

Next there is a need for an Azure AD app registration. It needs to have a client secret and the following delegated Graph API permissions:

SSO app registration – Necessary Api permissions
SSO app registration - Expose an Api and authorize client applications
SSO app registration – Expose an Api and authorize client applications

The app needs an App ID URI in the following format:

api://{{PUBLIC_HOSTNAME}}/{{TAB_APP_ID}}"

Get ID token

SSO in Microsoft Teams always starts client-side with the specific Teams JS SDK method:

if (inTeams === true) {
      authentication.getAuthToken({
          resources: [`api://${process.env.PUBLIC_HOSTNAME}/${process.env.TAB_APP_ID}`],
          silent: false
      } as authentication.AuthTokenRequestParameters).then(token => {
        getMails(token);
        setToken(token);
        app.notifySuccess();
      }).catch(message => {
          setError(message);
          app.notifyFailure({
              reason: app.FailedReason.AuthFailed,
              message
          });
      });
    }

Interestingly the inTeams state variable also works in other M365 products. Nevertheless the same code could also have been implemented in the parallel context useEffect hook. The returned token is only an ID token and put to the state but also directly used to retrieve user’s emails.

Call backend (Get Emails)

The first thing the app needs from the backend are the user’s recent emails. The only thing the client has to do here is to make one request to the backend sending over the ID token.

const getMails = async (token: string) => {
    const response = await Axios.get(`https://${process.env.PUBLIC_HOSTNAME}/api/mails`,
    { headers: { Authorization: `Bearer ${token}` }});
    setMails(response.data);
};

Server-side the request has to do three things:

  • Authenticate
  • Get access token with on behalf of (O-B-O) flow
  • Execute Microsoft Graph request
router.get("/mails",
    pass.authenticate("oauth-bearer", { session: false }),
    async (req: any, res: express.Response, next: express.NextFunction) => {
      const user: any = req.user;
      try {
        const accessToken = await exchangeForToken(user.tid,
          req.header("Authorization")!.replace("Bearer ", "") as string,
          ["https://graph.microsoft.com/mail.read"]);
        const mails = await getMails(accessToken);
        res.json(mails);
      }
      catch (err) {
        log(err);
        if (err.status) {
            res.status(err.status).send(err.message);
        } else {
            res.status(500).send(err);
        }
      }
  });

Authentication of client request takes place in line two and could be skipped if you use an Azure function or App Service together with Microsoft Identity Provider.

Next is getting the access token with exchangeForToken

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.TAB_APP_ID,
        client_secret: process.env.TAB_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);
        } else {
            resolve(result.data.access_token);
        }
      }).catch(err => {          
          reject(err); // error code 400 likely means you have not done an admin consent on the app
      });
    });
  };

This function is not only executed server-side but also the result, that is the access token, will only be used server-side and never be returned to the client. Secondly every time an access token is needed it will only request the currently needed permission scope although the used app registration has some more granted. In this first example it’s only https://graph.microsoft.com/mail.read

The only thing that will be returned client-side is the final result of the Microsoft Graph call which in detail is handled in the following function:

const getMails = async (accessToken: string): Promise<IMail[]> => {     
    const requestUrl: string = `https://graph.microsoft.com/v1.0/me/messages?$select=id,from,subject,receivedDateTime,hasAttachments&$orderby=receivedDateTime desc`;
    const response = await Axios.get(requestUrl, {
        headers: {          
            Authorization: `Bearer ${accessToken}`,
    }});
    let mails: IMail[] = [];
    response.data.value.forEach(element => {
      mails.push({ 
                  id: element.id,
                  from: element.from.emailAddress.name,
                  subject: element.subject,
                  hasAttachments: element.hasAttachments,
                  receivedDateTime: element.receivedDateTime });
    });
    return mails;
  };

To repeat it once again, all server operations follow the same pattern: Call the express router with the ID token for authentication, Exchange the ID token to an access token with the on behalf of flow and finally make the Microsoft Graph call using that access token only server-side. Only the resource objects as result will be returned client-side in the very end.

Distinct between Teams and Outlook/Office

To distinct between the different M365 products where the app is running in the context.app.host.name can be used.

useEffect(() => {
    if (context) {
      switch (context.app.host.name) {
        case "Teams":
          setDialogContent(<Teams getJoinedTeams={getJoinedTeams} currentFolder={currentFolder} getFolders={getFolders} mail={mails[selectedIndex!]} />);
          break;
        case "Outlook":
        default:
          setDialogContent(<OneDrive currentFolder={currentFolder} getFolders={getFolders} mail={mails[selectedIndex!]} />);
          break;
    }
  }
}, [token, currentFolder, selectedIndex]);
return (
    <Provider theme={theme}>
      <Flex fill={true} column styles={{
          padding: ".8rem 0 .8rem .5rem"
      }}>
        <Flex.Item>
          <div className="button">
          <Dialog
              cancelButton="Cancel"
              confirmButton="Save here"
              content={dialogContent}
              onConfirm={saveMail}          
              header="Select storage location"
              trigger={<Button content="Save Mail" primary disabled={typeof selectedIndex === 'undefined' || (selectedIndex!<0)} />}
            />

The scenario is if the app is running in Teams a dialog to select a team and its corresponding folders shall be opened. In all other cases such as Outlook or Office a dialog with personal OneDrive folders shall be opened. Depending on context.app.host.name the one or the other custom react component (<Teams /> vs <OneDrive />) is rendered inside the Dialog component.

App in action inside Teams
App in action inside Teams
App in action inside Outlook
App in action inside Outlook

Simple and resumable file upload

The main goals of this post as there are to extend Microsoft Teams application to other M365 products and use SSO with it were already explained. Nevertheless this sample solution got a bit more comfortable and there’s still room to explain more features. One is the file upload with Microsoft Graph. Files smaller 4MB can be uploaded with a simple PUT. But bigger files need a resumable upload session.

const saveMail = async (driveID: string, folderID: string, mailId: string, mailSubject: string, accessToken: string) => {
    const mailMIMEContent = await getMailContent(mailId, accessToken);
    const fileName = createMailFileName(mailSubject);
    let mailDriveItem: any = null;
    if (mailMIMEContent.length < (4 * 1024 * 1024)) {     // If Mail size bigger 4MB use resumable upload
      mailDriveItem = await storeMail2OneDrive(driveID, folderID, mailMIMEContent, fileName, accessToken);
    }
    else {
      mailDriveItem = await saveBigMail(driveID, folderID, mailMIMEContent, fileName, accessToken);
    }
};

This parent saveMail function first receives the MIME content of the mail and then differentiates between a size bigger or smaller 4MB. In case of bigger next a resumable upload session needs to be established with Microsoft Graph:

const saveBigMail = async (driveID: string, folderID: string, mimeStream: string, fileName: string, accessToken: string) => {
    const sessionOptions = {
      "item": {
        "@microsoft.graph.conflictBehavior": "rename"
      }
    };
    let requestUrl = `https://graph.microsoft.com/v1.0/drives/${driveID}/`;
    requestUrl += driveID !== folderID ? `items/${folderID}:/${fileName}.eml:/createUploadSession` : `root:/${fileName}.eml:/createUploadSession`;
    const response = await Axios.post(requestUrl, sessionOptions,
      {
      headers: {          
          Authorization: `Bearer ${accessToken}`,
      }
    });
    const resp = await uploadMailSlices(mimeStream, response.data.uploadUrl);
    return resp.data;   
};

The result is an uploadUrl against which the MIME content can be uploaded in slices.

const uploadMailSlices = async (mimeStream: string, uploadUrl: string) => {
    let minSize=0;
    let maxSize=5*327680; // 5*320kb slices --> MUST be a multiple of 320 KiB (327,680 bytes)
    while(mimeStream.length > minSize) {
      const fileSlice = mimeStream.slice(minSize, maxSize);
      const resp = await uploadMailSlice(uploadUrl, minSize, maxSize, mimeStream.length, fileSlice);
      minSize = maxSize;
      maxSize += 5*327680;
      if (maxSize > mimeStream.length) {
        maxSize = mimeStream.length;
      }
      if (resp.id !== undefined) {
        return resp;
      } 
    }
};

The MIME content needs to be portioned in 320kB slices which then can be uploaded (without authorization, therefore you already have the session and uploadUrl) against the given uploadUrl.

const uploadMailSlice = async (uploadUrl: string, minSize: number, maxSize: number, totalSize: number, fileSlice: string) => {
    // Here no authorization anymore, only in createUploadSession!
    const header = {
      "Content-Length": `${maxSize - minSize}`,
      "Content-Range": `bytes ${minSize}-${maxSize-1}/${totalSize}`
    };
    const response = await Axios.put(uploadUrl, fileSlice,
    {
      headers: header
    });
    return response.data;
};

Use Microsoft Graph openExtensions to store custom metadata with the mail

To not lose overview which mail was already stored where, wouldn’t it be great to save storage information as custom metadata with each mail handled? The answer could be Microsoft Graph openExtensions (there are also schema extensions which do not really suit here which I already explained here)

After each mail storage operation the custom metadata is stored with the mail the following way:

const metadataExtensionName = "customButUnique.onmicrosoft.MailStorage";

const saveMailMetadata = async (mailId: string, displayName: string, url: string, accessToken: string) => {
    const savedDate: Date = new Date();
    const metadataBody = {
      "@odata.type" : "microsoft.graph.openTypeExtension",
      "extensionName" : metadataExtensionName,
      "saveDisplayName" : displayName,
      "saveUrl" : url,
      "savedDate" : savedDate.toISOString()
    };
    let requestUrl = `https://graph.microsoft.com/v1.0/me/messages/${mailId}/extensions`;
    
    const response = await Axios.post(requestUrl, metadataBody,
      {
      headers: {          
          Authorization: `Bearer ${accessToken}`,
      }
    }); 
  };

First we need to define a custom but unique metadataExtensionName. Additionally we have the URL from the stored driveItem, a display name and the date. The rest is another Microsoft Graph operation against the extensions endpoint of the message.

While getting the emails any potential open extensions need to be retrieved by an expand as well.

requestUrl += "&$expand=Extensions($filter=id+eq+'Microsoft.OutlookServices.OpenTypeExtension.mmsharepoint.onmicrosoft.MailStorage')";
response.data.value.forEach(element => {
      mails.push({ 
              ...
              alreadyStored: element.extensions !== 'undefined' && element.extensions !== null && element.extensions.length > 0,
              savedUrl: element.extensions !== 'undefined' && element.extensions !== null && element.extensions.length > 0 ? element.extensions[0].saveUrl : "",
              savedDisplayName: element.extensions !== 'undefined' && element.extensions !== null && element.extensions.length > 0 ? element.extensions[0].saveDisplayName : "",
              savedDate: element.extensions !== 'undefined' && element.extensions !== null && element.extensions.length > 0 ? element.extensions[0].savedDate : "" });
});

On client-side potential storage metadata will be identified in two situations. While rendering the list item and in the potential next save dialog:

mails.forEach((m) => {
        listItems.push({ 
            header: m.from, 
            content: m.subject, 
            media: m.hasAttachments ? (attachmentIcon) : "", 
            headerMedia: m.receivedDateTime, 
            endMedia: m.alreadyStored ? savedIcon : "" });
});
ListItem indicating mail already stored
ListItem indicating mail already stored
 return (
    <div>
      {props.mail.alreadyStored &&
          <div className='saveHint'>
            <div><RedbangIcon /> You already saved this mail on <span>{new Date (props.mail.savedDate).toLocaleString()}</span></div>
            <div>to <a href={props.mail.savedUrl}>{props.mail.savedDisplayName}</a></div>
          </div>}
      <Breadcrumb>
        ....
Warning hint if mail was saved already and where
Warning hint if mail was saved already and where

You’ve seen a comfortable personal Teams tab application with many features, especially many Microsoft Graph endpoints. All implemented based on single-sign-on (SSO) in a secure and best practice way. It was shown how the same application could be implemented slightly different on various M365 products. Not yet, everything is possible but I can’t wait to see the same solution idea running in a messaging extension inside Outlook and inside an existing mail in inbox.

To have a look at the whole solution refer to my GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Use Microsoft Graph delta approach to increase performance getting SharePoint list items

Use Microsoft Graph delta approach to increase performance getting SharePoint list items

One of my most popular blogposts all time was Use Micrsosoft Graph to query SharePoint items. Today I want to give another reason to use Microsoft Graph instead of SharePoint Rest Api. One reason that is exclusive to Microsoft Graph (and I already wrote about in the past dealing with different resource endpoints): Use the delta approach from Microsoft Graph to increase performance, now with SharePoint List Items. That is, retrieving only items that changed since last time the call was used.

At the time of writing this post the capability of using delta approach with SharePoint List Items is still in “beta” which means it might be subject to change and is not supported in production scenarios. Nevertheless I hope to see this in v1.0 soon and the general approach is approved and recommended with other resource endpoints since years…

Microsoft Graph API documentation

Content

General access

To query SharePoint list items the following endpoint can be used:

https://graph.microsoft.com/v1.0/sites/{site-id}/lists/{list-id}/items  

While the list-id is a simple Guid and nothing special the site-id is different and even the term site-id might be misleading in that case. See the result of the retrieval of a specific site based on it’s relative url such as:

https://graph.microsoft.com/v1.0/sites/mmsharepoint.sharepoint.com:/sites/Site1

The returned site object, respectively it’s id will look like this:

{…
    "id": "mmsharepoint.sharepoint.com,479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10",
…}

The id consists of three parts returned: The host-part of the site(-url) a first guid that represents the id of the Site Collection (SPSite) and a second part that represents the id of the Website (SPWeb). With that id the usage of above’s endpoint would look like this:

https://graph.microsoft.com/v1.0/sites/mmsharepoint.sharepoint.com,479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10/lists/6ee1fec0-88ce-40d5-a0f8-fe75d843266c/items  

But it can also be used in a shorter way like this (leave out the host part):

https://graph.microsoft.com/v1.0/sites/479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10/lists/6ee1fec0-88ce-40d5-a0f8-fe75d843266c/items 

So programmatically inside a SharePoint Framework solution for instance all parameters might be available from the context. Or use Graph queries. Above there was one for a site (having the relative Url). A list ID can be evaluated on the DisplayName of a list for instance:

https://graph.microsoft.com/v1.0/sites/479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10/lists?$filter=displayName eq '<My List Title>'

Get initial delta

To get the initial delta there are two approaches. First one is to simply add /delta to above’s query:

https://graph.microsoft.com/v1.0/sites/mmsharepoint.sharepoint.com,479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10/lists/6ee1fec0-88ce-40d5-a0f8-fe75d843266c/items/delta  

The second approach we will come to later. This first one returns all items together with a specific @odata.deltaLink. This link is the combination of previous query combined with a specific delta token.

Now this delta link can be called directly. It will return an empty result set as long as nothing changes. Once an item changes it will be returned. Interestingly even deleted items will be returned the following way:

{
    "@odata.context": "https://graph.microsoft.com/beta/$metadata#Collection(listItem)",
    "@odata.deltaLink": "https://graph.microsoft.com/beta/...",
    "value": [
        {
            "@odata.type": "#microsoft.graph.listItem",
            "id": "8",
            "parentReference": {
                "siteId": "479ceff8-2da5-483b-ae0b-3268f5d9487b"
            },
            "deleted": {
                "state": "deleted"
            },
            "fields": {}
        }
   ]
}

OData operations

OData operations such as $select, $expand, and $top are supported as well and, if used, re-occur in the @odata.deltaLink. So a query like:

https://graph.microsoft.com/v1.0/sites/mmsharepoint.sharepoint.com,479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10/lists/6ee1fec0-88ce-40d5-a0f8-fe75d843266c/items/delta?$expand=fields($select=Title,Lastname,Salary)  

will return a result like:

{
    "@odata.context": "https://graph.microsoft.com/beta/$metadata#Collection(listItem)",
    "@odata.deltaLink": "https://graph.microsoft.com/beta/sites/479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10/lists/Employees/items/delta?$expand=fields(%24select%3dTitle%2cLastname%2cSalary)&token=MzslMjM0OyUyMzE7Mzs1NzkyZjYwMy1mNThiLTQ0ZDktYjZlMC02YzQzODAwMDE1YWE7NjM3OTY2NjcwMTI1NzAwMDAwOzY2NzY4MjEzNDslMjM7JTIzOyUyMzI",
    "value": [
              { ... }
             ]

       

A $filter operation seems not to be supported at the moment. It’s not listed in the documentation and on trial it produces an empty result even on the initial call.

Token

The token used in the @odata.deltaLink represents more or less the state, respectively the point of time, the query was called last time. Instead of using a specific token also “latest” can be used which would simply reset and return a new token coming from the current point of time.

https://graph.microsoft.com/beta/sites/479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10/lists/Employees/items/delta?$expand=fields(%24select%3dTitle%2cLastname%2cSalary)&token=latest

This can be useful especially in error handling situations when a specific token does not perform anymore. This can be the case as a token is only valid for a specific amount of time. For groups resource for instance this is only 30 days before the token becomes invalid. I was not yet able to detect the specific amount of days a token is valid for listItem resource but would expect this to be less or equal to groups.

For a developer of course it is essential to persist the token, respectively the @odata.deltaLink until the next call. And as mentioned above it is also essential to execute the call within the amount of time the token is still valid. Otherwise a new initial delta needs to be requested.

DriveItems

Also drive items can be handled quite the same way as they support Microsoft Graph’s delta approach as well. The delta can be called directly from /root:

https://graph.microsoft.com/v1.0/sites/mmsharepoint.sharepoint.com,479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10/drives/b!UajMG0ZVx0eHlqNl0jHE2qdU0dRs4WRDhmiAvcKE9j8HcWSMwmJOS4IaYAcea_X-/root/delta

To mention here is that this already works under v1.0. Unfortunately a combination with the listItem does not work:

https://graph.microsoft.com/v1.0/sites/mmsharepoint.sharepoint.com,479ceff8-2da5-483b-ae0b-3268f5d9487b,c23c1e73-9fab-4534-badf-3f4cbc373d10/drives/b!UajMG0ZVx0eHlqNl0jHE2qdU0dRs4WRDhmiAvcKE9j8HcWSMwmJOS4IaYAcea_X-/root/delta?$expand=listItem

Also the opposite is not working: Coming from a list and $expand it to driveItem. In general the listItem delta seems not to work with document libraries(, yet?)

/lists/Documents/items/delta
/lists/cc11f284-f9f5-4441-b74a-e52443d4736d/items/delta

does not work if Documents / cc11f284-f9f5-4441-b74a-e52443d4736d is a document library.

As a conclusion I would still recommend the delta approach from the current point of view as it might significantly reduce the # of items to be processed. Use driveItem delta approach for libraries and listItem delta approach for lists. Move over to driveItem from listItem or to listItem from driveItem if needed and while processing the item.

But let’s hope that the connection between listItem and driveItem gets better as well as the possibility to $filter before it gets v1.0. I would also love to see this in combination with the search endpoint of Microsoft Graph.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Teams Meeting App API in Bot activity handlers

Teams Meeting App API in Bot activity handlers

In my last post I handled Meeting apps Api references, especially the corresponding Rest API. This time I want to concentrate on how to use the Bot framework SDK methods in Bot activity handlers.

This will be done by enhancing the previous solution with opening a Teams task module based on adaptive cards and forcing a bot activity to reload the data.

Teams Meeting App tab with option to open reload task module
Teams Meeting App tab with option to open reload task module
Task module based on adaptive card with reloaded Teams meeting details
Task module based on adaptive card with reloaded Teams meeting details

Series

Content

Setup solution

The solution set up is nothing different than last time:

yo teams – Setup Teams Meeting Details app
yo teams – Setup Teams Meeting Details app

The Azure bot and the teams manifest modification also stay the same than last time.

"configurableTabs": [
    {
      "configurationUrl": "https://{{PUBLIC_HOSTNAME}}/meetingDetailsTab/config.html?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
      "canUpdateConfiguration": true,
      "scopes": [
        "team",
        "groupchat"
      ],
      "context": [
        "meetingChatTab",
        "meetingDetailsTab",
        "meetingSidePanel",
        "meetingStage"
      ],
      "meetingSurfaces": [
        "sidePanel",
        "stage"
      ]
    }
  ],
…
  "webApplicationInfo": {
    "id": "{{MICROSOFT_APP_ID}}",
    "resource": "https://RscPermission"
  },
  "authorization": {
      "permissions": {
          "resourceSpecific": [
              {
                  "name": "OnlineMeeting.ReadBasic.Chat",
                  "type": "Application"
              },
              {
                "name": "ChannelMeeting.ReadBasic.Group",
                "type": "Application"
              }
          ]
      }
  }

Client-side implementation

Client-side an additional button is placed at the bottom of the existing details page. This button is responsible to open open an initial task module.

export const MeetingDetails = (props) => {
  const reloadDetails = React.useCallback(() => {
    props.reloadDetails();
  },[props.reloadDetails]);
...
  <Button title="Reload Details" onClick={reloadDetails} primary>Reload Details</Button>

Worth to mention is, here a React.useCallback hook occurs which depends on the corresponding props function (every time this changes only it is Re-rendered). So the magic and action takes place in the parent component:

const reloadMeetingDetails = React.useCallback(() => {
    InitMeetingDetailsCard!.actions![0].data!.data.meetingId = meetingId!;
    const initCardAttachment = {
                                contentType: "application/vnd.microsoft.card.adaptive",
                                content: InitMeetingDetailsCard };
    const taskModuleInfo: TaskInfo = {
        title: "Reload Details",
        card: JSON.stringify(initCardAttachment),
        width: 300,
        height: 250,
        completionBotId: process.env.MICROSOFT_APP_ID
    };
    tasks.startTask(taskModuleInfo, reloadMeetingDetailsCB);
}, [meetingId]);
const reloadMeetingDetailsCB = React.useCallback(() => {
    getDetails(meetingId!);     
}, [meetingId]);

A simple adaptive card is provided with the current meetingId and used to open a simple initial task module.

Although already on Teams JS SDK 2.0 here still tasks is used instead of Dialogs as adaptive cards are not yet supported at the time of writing this post

Microsoft documentation

Bot implementation

After the initial task module is submitted to retrieve the data the central handleTeamsTaskModuleSubmit operation is entered:

protected async handleTeamsTaskModuleSubmit(_context: TurnContext, _taskModuleRequest: TaskModuleRequest): Promise<any> {
    let meetingID = "";
    switch (_taskModuleRequest.data.verb) {
      case "getMeetingDetails":
        meetingID = _taskModuleRequest.data.data.meetingId;
        const meetingDetails = await TeamsInfo.getMeetingInfo(_context, meetingID) as IMeetingDetails;
        const card = getMeetingDetailsCard(meetingDetails);
        const Response: TaskModuleResponse = {
          task: {
            type: 'continue',
            value: {
              title: "Your Meeting Details",
              height: 500,
              width: "large",
              card: CardFactory.adaptiveCard(card),
            } as TaskModuleTaskInfo
          }
        };
        return Promise.resolve(Response);
        break;   
      case "getParticipantDetails":
        ....
      default:
        store.setItem("serviceUrl", _context.activity.serviceUrl);
        return null;
    }
  }

Every task module submit action reaches this function. Based on the verb a switch separates in three blocks. The default block is quite straightforward and responsible for closing. But before it is storing the serviceUrl again. This is to ensure the serviceUrl is in memory even if the application got recycled.

The other two blocks are quite similar but differentiate which data to retrieve. As an example here the meeting details are shown.

At first the input parameters are taken from the request and then with the corresponding TeamsInfo. method the data is retrieved and incorporated into an adaptive card which is finally returned.

Participant details are handled quite similar except there are more input parameters. In the UI it looks like the following:

Participant details tab
Participant details tab
Init Details Reload ​
Init Details Reload
Adaptive card task module with participant details​
Adaptive card task module with participant details

This advanced part handling Teams meeting app API showed Bot framework SDK methods and how to use them in Bot activity handlers. For a whole reference of the solution refer to my GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Teams Meeting Details with Bot Framework SDK

Teams Meeting Details with Bot Framework SDK

Recently I wrote lots about Microsoft Teams Meeting Apps. What I didn’t cover, yet, but this post intends to handle are Meeting apps Api references. Especially it will cover those handled with the Bot Framework SDK and the corresponding Rest API. This is how it is intended to be implemented by Microsoft. The downside of course is the necessity of a bot. There is an alternative Yannick Reekmans wrote about by decoding the teams meeting ID locally and directly use Microsoft Graph calls. But if you want to or need to implement a bot anyway then simply continue reading.

Series

Content

Azure Bot

Setup

First there’s a need to set up an Azure bot. This is done in Azure portal.

Create new Azure Bot resource
Create new Azure Bot resource – Settings

It is very important to have a multi-tenant app here. You can either select an existing App ID or create a new one. Next a Teams channel needs to be added.

Create new Azure Bot resource – Add Teams channel

Finally the bot needs some configuration. The messaging endpoint needs to be constructed from your public hostname (here an ngrok url). Then the App ID needs to be given a secret. Therefore click on “Manage” as marked.

Create new Azure Bot resource – Endpoint configuration

Under “Certificates and secrets” click “New client secret” and immediately copy and paste it to your local .env file which is not possible later on. Or even better store this in Azure Key Vault 😉👏🏻

Credentials

Bot credentials – Add New client secret

Setup solution

To set up the solution run yo teams with the following settings for instance:

yo teams – Setup Teams Meeting Details app

Additionally some modifications to the teams app manifest are needed. Under configurableTabs context and meetingSurfaces need to be added to enable the tab to occur in a teams meeting app.

"configurableTabs": [
    {
      "configurationUrl": "https://{{PUBLIC_HOSTNAME}}/meetingDetailsTab/config.html?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
      "canUpdateConfiguration": true,
      "scopes": [
        "team",
        "groupchat"
      ],
      "context": [
        "meetingChatTab",
        "meetingDetailsTab",
        "meetingSidePanel",
        "meetingStage"
      ],
      "meetingSurfaces": [
        "sidePanel",
        "stage"
      ]
    }
  ],
…
  "webApplicationInfo": {
    "id": "{{MICROSOFT_APP_ID}}",
    "resource": "https://RscPermission"
  },
  "authorization": {
      "permissions": {
          "resourceSpecific": [
              {
                  "name": "OnlineMeeting.ReadBasic.Chat",
                  "type": "Application"
              },
              {
                "name": "ChannelMeeting.ReadBasic.Group",
                "type": "Application"
              }
          ]
      }
  }

Furthermore the bot needs permissions to access the current meeting. This is done by giving resource specific consent (RSC) permissions. In fact this means on every installation of the app to a specific teams meeting permissions to this specific meeting only are granted to the bot’s app ID. The permissions to be granted are either for channel meetings or private meetings. If you want to check if permissions were granted successfully you can use the following Microsoft graph call:

https://graph.microsoft.com/beta/chats/{conversationID}@thread.v2/permissionGrants

Bot and API implementation

Rest API

The rest API for getting meeting details is the following relative endpoint:

/v1/meetings/${meetingId}

To construct the full URL you need to get the serviceUrl from the bot. This can be done inside the bot’s TeamsActivityHandler from where it can be stored in memory.

class BotActivityHandler extends TeamsActivityHandler {
    constructor(public conversationState: ConversationState, userState: UserState) {
        super();
        …
        this.onConversationUpdate(async (context, next) => {
            serviceUrl = context.activity.serviceUrl;
            store.setItem("serviceUrl", serviceUrl);
...
        });

In this case the serviceUrl is only evaluated onConversationUpdate. In fact this means once the bot is installed to a meeting or a member/participant is added or removed.

Having the serviceUrl it can be used in a very own backend API class anytime.

export const homeService = (options: any): express.Router => {
    async function getMeetingDetails(req, res)
    {
        const meetingId = req.params.meetingID;
        const credentials = new MicrosoftAppCredentials(process.env.MICROSOFT_APP_ID!, process.env.MICROSOFT_APP_PASSWORD!);
        const token = await credentials.getToken();
        const serviceUrl = store.getItem("serviceUrl");
        const apiUrl = `${serviceUrl}/v1/meetings/${meetingId}`;  
        Axios.get(apiUrl, {
            headers: {          
                Authorization: `Bearer ${token}`
            }})
            .then((response) => {
                res.send(response.data);
            }).catch(err => {
                log(err);
                return null;
            });        
    }

And if you want to understand why the bot app needs to be a multi-tenant one then check out the token, especially the audience which is botframework.com and not that one of your own tenant.

Bot SDK

Th Bot SDK also offers functionality similar to Rest API which looks like the following:

this.onConversationUpdate(async (context, next) => {
        try {
            const meetingID = context.activity.channelData.meeting.id;
            const meetingDetails = await TeamsInfo.getMeetingInfo(context, meetingID);
            store.setItem(`meetingDetails_${meetingID}`, meetingDetails);
        }
        catch(err) {
            log(err);
        };
});

The downside is, this can only be used inside TeamsActivityHandler. So in this sample it is stored in memory same like serviceUrl above but that might not be the latest state. So this functionality mostly makes sense in real bot activity.

export const homeService = (options: any): express.Router => {
    async function getMeetingDetails(req, res)
    {
        const meetingId = req.params.meetingID;     
        const meetingDetails = store.getItem(`meetingDetails_${meetingId}`);
        log(meetingDetails);
        res.send(meetingDetails);
    }

At a later point of time of course the details object can be retrieved from memory in an own class. But especially for participants data the gap between retrieval in bot and usage in own API might be too broad. So especially the particiant details should be retrieved by using serviceUrl with Rest API in custom backend service:

async function getMeetingParticipantDetails(req, res)
{
    const meetingId = req.params.meetingID;
    const participantId = req.params.userID;
    const tenantId = req.params.tenantID;
    const credentials = new MicrosoftAppCredentials(process.env.MICROSOFT_APP_ID!, process.env.MICROSOFT_APP_PASSWORD!);
    const token = await credentials.getToken();
    const serviceUrl = store.getItem("serviceUrl");
    const apiUrl = `${serviceUrl}/v1/meetings/${meetingId}/participants/${participantId}?tenantId=${tenantId}`;
    Axios.get(apiUrl, {
      headers: {
          Authorization: `Bearer ${token}`
      }})
      .then((response) => {
        res.send(response.data);
      }).catch(err => {
        log(err);
        return null;
      });        
}

Except a specific Rest API endpoint the function looks quite the same as above.

Client-side implementation

Client-side the data simply needs to be retrieved from the custom backend API and rendered.

const getDetails = async (meetingID: string) => {        
    const response = await Axios.post(`https://${process.env.PUBLIC_HOSTNAME}/api/getDetails/${meetingID}`);
    setMeetingDetails(response.data); 
};
const getParticipant = async (meetingID: string, userId, tenantId) => {        
    const response = await Axios.post(`https://${process.env.PUBLIC_HOSTNAME}/api/getParticipantDetails/${meetingID}/${userId}/${tenantId}`);
    setMeetingParticipant(response.data);
};
const onActiveIndexChange = (event, data) => {
    setActiveMenuIndex(data.activeIndex);
};
return (
  <Provider theme={theme}>
    <Flex fill={true} column styles={{
        padding: ".8rem 0 .8rem .5rem"
    }}>
      <Flex.Item>
        <Header content="Meeting Information" />
      </Flex.Item>
      <Flex.Item>
        <div>
          <Menu
              defaultActiveIndex={0}
              activeIndex={activeMenuIndex}
              onActiveIndexChange={onActiveIndexChange}
              items={menuItems}
              underlined
              primary
              accessibility={tabListBehavior}
              aria-label="Meeting Information"
          />
          <div className="l-content">
              {activeMenuIndex === 0 && <MeetingDetails meetingDetails={meetingDetails} />}
              {activeMenuIndex === 1 && <MeetingParticipant meetingParticipant={meetingParticipant} />}
          </div>
        </div>

Both objects, the meeting details and the participant details are retrieved and displayed alternatively in a tabbed menu. MeetingDetails for instance are rendered in this stateless component:

export const MeetingDetails = (props) => {
  return (
    <Grid className="l-text" columns="150px 80%">
      <Segment
          styles={{
          gridColumn: 'span 2',
          }}>
          <h2>Meeting details</h2>
      </Segment>
      <Text content="ID" />
      <Text content={props.meetingDetails?.details.id} />
      <Text content="Title" />
      <Text content={props.meetingDetails?.details.title} />
…

The result will look like this::

Meeting Details – Rendered in Grid
Meeting Participant details – Rendered in Grid

Summary and outlook

This first little post showed initial details on Teams Meeting apps Api references. In a further scenario I might more concentrate on the Bot framework SDK directly used in bot activities. As usual for your reference you can have a look at this sample in my GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Use FluidFramework in a Microsoft Teams app

Use FluidFramework in a Microsoft Teams app

While producing my last sample on Microsoft teams meeting app I discovered the challenge to synchronize real time data. This led to my exploration of Microsoft’s FluidFramework. While in previous parts I explained how to simply deal with it in the frontend (part 1) and how to have a reliable storage in the backend (part 2) now it’s finally time to put the pieces together and implement everything in a (my former) Teams app. So grab your popcorn 🍿 and let’s vote for favorite movies again.

Series

Content

Setup

To set up the app we need to scaffold a Teams tab application. This time it needs to include SSO. Of course the manifest needs to consider Teams meeting app settings like described in my previous sample.

Teams Meeting tab setup in yo Teams with SSO

Additionally for Microsoft FluidFramework and Azure Fluid Relay service the following npm packages need to be installed:

npm i fluid-framework @fluidframework/azure-client @fluidframework/test-client-utils

Configuration

AzureConnectionConfig

Since FluidFramework version 1 there is a distinction between a local and a remote configuration. While the local one is quite straightforward the remote one needs parameters from the Azure Fluid Relay service.

const useAzure = true; // | false
const AzureLocalConnection: AzureLocalConnectionConfig = {
  type: "local",
  tokenProvider: new InsecureTokenProvider("c51b27e2881cfc8d8101d0e1dfaea768", { id: userID }), // Problematic to have secret here in client-side code
  endpoint: process.env.REACT_APP_REDIRECTURI!,
};
const AzureRemoteConnection: AzureRemoteConnectionConfig = {
  type: "remote",
  tenantId: process.env.REACT_APP_TENANT_ID!,
  tokenProvider: new AzureFunctionTokenProvider(process.env.REACT_APP_AZURETOKENURL + "/api/FluidTokenProvider", { userId: userID, userName: "Test User" }),
  endpoint: process.env.REACT_APP_ORDERER!
};
export const connectionConfig: AzureClientProps = useAzure ? { connection: AzureRemoteConnection} : { connection: AzureLocalConnection } ;
Azure Fluid Relay – Access information

App installation

While in the previous posts the containerId was stored in the app url once one was established (and we did not really care how and where to persist it btw…) which looked liked this:

http://localhost:3000/#a31ffdc3-f230-48fb-83de-38632a30c46c

In Teams this needs to be handled differently. The Microsoft tutorial suggests to establish a container during app setup (installation) and persist the containerId into app’s contentUrl.
The disadvantage here is that in case of an app reconfiguration any existing containerId might get lost. But here is a more flexible way. As this app needs an app configuration (for the movie urls) anyway let’s put the containerId there. Furthermore I optionally allow to “reset” it. Maybe someone wants to change movies AND clear existing votes.

Teams app configuration with urls and option to reset container (of votes)
const saveConfig = async (idToken: string, saveEvent: pages.config.SaveEvent) => {
    if (reset) {
      setContainerId("");
    }
    const currentContainerId = await getFluidContainerId(context?.user?.userPrincipalName!, idToken, containerId);
    const host = "https://" + window.location.host;
    pages.config.setConfig({
      contentUrl: host + "/voteMovieFluidTab/?" + 
          containerIdQueryParamKey + "=" + currentContainerId +
          "&name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
      websiteUrl: host + "/voteMovieFluidTab/?" + 
          containerIdQueryParamKey + "=" + currentContainerId +
          "&name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
      suggestedDisplayName: "Vote Movie Fluid",
      removeUrl: host + "/voteMovieFluidTab/remove.html?theme={theme}",
      entityId: entityId.current
    }).then(() => {
      Axios.post(`https://${process.env.PUBLIC_HOSTNAME}/api/config/${meetingID.current}`,
                { config: { movie1url: movieRef1.current, movie2url: movieRef2.current, movie3url: movieRef3.current, containerId: currentContainerId }});
      saveEvent.notifySuccess();
    });
  };

In code at first any existing containerId is reset to blank if the user clicked the corresponding Toggle. Next a container is either created or a connection to a given one is established. Then the containerId is written to contentUrl and websiteUrl as the Microsoft tutorial suggests. But finally the containerId is also written to the app configuration store together with the movie urls. From here it can also be easily retrieved while next time the settings page is opened for re-configuration and in case the user does not want a reset any given containerId can be kept.

const loadConfig = async (meeting: string) => {
    Axios.get(`https://${process.env.PUBLIC_HOSTNAME}/api/config/${meeting}`).then((response) => {
        const config = response.data;
        setMovie1(config.movie1url);
        setMovie2(config.movie2url);
        setMovie3(config.movie3url);
        setContainerId(config.containerId);
    });
  };
useEffect(() => {
    if (context) {
      let meeting = "";
      meeting = context.meeting?.id!;
      meetingID.current = meeting;
      loadConfig(meeting);
      pages.config.registerOnSaveHandler(onSaveHandler);
      pages.config.setValidityState(true);
      app.notifySuccess();
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [context]);

SSO

For a secure connection to the Azure Fluid Relay service a Token service provider represented by an Azure function is needed. I already explained this Azure function in the previous part and you can also refer to it in my GitHub repository.

If this Azure function requires user authentication the teams app needs an identity token to access this Azure function. To get this identity token an application ID is needed. This application ID comes from the identity provider of the Azure function. In the previous part of this series the configuration of the Azure function identity provider was already introduced. If the identity provider application is configured as needed for Teams tab SSO additionally two steps are needed. But first in short the regular SSO steps here. This needs to be applied to the app registration of the Azure Function’s identity provider:

  • Expose an API and give the Api URI a name like api://xxxx.ngrok.io/<Your App ID> (xxxx depends on your current ngrok Url)
  • Set scope name to access_as_user and provide Admin & User messages
  • Add Teams Client 1fec8e78-bce4-4aaf-ab1b-5451cc387264 and Teams Web Client 5e3ce6c0-2b1f-4285-8d4b-75ee78787346 IDs under “Add a client application”

Additionally the teams app URL needs to be added to the CORS settings of the Azure function. And the app ID URI needs to be added as allowed token audience to the identity provider.

Teams app url added to CORS of TokenServiceProvider Azure Function
AppIdURI as allowed token audience for Azure Function’s identity provider

Having everything configured the implementation of SSO looks like the given example from the configuration page:

const secureAccess = true; // false
…
if (secureAccess) {
      if (inTeams === true) {
        authentication.getAuthToken({
            resources: [process.env.TAB_APP_URI as string],
            silent: false
        } as authentication.AuthTokenRequestParameters).then(token => {
          saveConfig(token, saveEvent)
            
        }).catch(message => {
          app.notifyFailure({
              reason: app.FailedReason.AuthFailed,
              message
          });
        });
      }
    }
    else {
      saveConfig("", saveEvent);
    }  
  };
  const saveConfig = async (idToken: string, saveEvent: pages.config.SaveEvent) => {
    if (reset) {
      setContainerId("");
    }
    const currentContainerId = await getFluidContainerId(context?.user?.userPrincipalName!, idToken, containerId);
…
}

If the app is configured to use secure access an ID token is generated and used for establishing the fluid container connection. If not the token is handed in as blank. As known from the previous part in case the fluid connection function receives a token a custom AzureFunctionTokenProviderSec is used.

if (authToken !== "") {
    connectionConfig.connection.tokenProvider = new AzureFunctionTokenProviderSec(process.env.REACT_APP_AZURETOKENURL + "/api/FluidTokenProvider", authToken, { userId: userID, userName: "Test User" });
  }

Client implementation

Establish Fluid Relay connection

In the parent client-side JSX component the connection to the Fluid backend container represented by a SharedMap is established.

const setFluidAccess = (token: string, containerId: string) => {
    getFluidContainer(context?.user?.userPrincipalName!, token, containerId)
      .then((fluidContainer) => {
        if (fluidContainer !== undefined) {
          const sharedVotes = fluidContainer.initialObjects.sharedVotes as SharedMap;
          setFluidContainerMap(sharedVotes);
        }
      });
 };

Although FluidFramework can deal with different objects a SharedMap is ideal here. As in this case there’s a need to store the following four values:

// Initialize votes
const sharedVotes = container.initialObjects.sharedVotes as SharedMap;
sharedVotes.set("votes1", 0);
sharedVotes.set("votes2", 0);
sharedVotes.set("votes3", 0);
sharedVotes.set("votedUsers", "");

Voting

Having a connection to the SharedMap coming from the parent component the voting is quite easy. Once the Vote button is clicked the corresponding vote() action is called. This writes an increased value to the SharedMap. An event receiver synchronizes this with the state variable responsible for the UI text value. While the write operation to the SharedMap automatically takes care for correspondence/sync with other clients.

const vote = async () => {    
    let votedUsers = props.votingMap.get("votedUsers");
    votedUsers += `;${props.userID}`;
    props.votingMap.set("votedUsers", votedUsers);
  };
  useEffect(() => {
    evalVotable();
  }, []);

  React.useEffect(() => {
    const updateVotes = () => {
      setVotes1(props.votingMap.get("votes1")!);
      setVotes2(props.votingMap.get("votes2")!);
      setVotes3(props.votingMap.get("votes3")!);
      evalVotable();
    };

    props.votingMap.on("valueChanged", updateVotes);

    return () => {
      props.votingMap.off("valueChanged", updateVotes);
    };
  });
....
{votable &&
              <div>
                <Button className="voteBtn" onClick={() => { props.votingMap.set("votes1", votes1! + 1); vote(); }}>Vote Movie 1</Button>
              </div>}

Result and summary

With Microsoft FluidFramework and Azure Fluid Relay service the given Teams application still looks the same as introduced in my previous post.

SidePanel movie voting
StageView watch most voted movie

But with some configuration effort and, depending on your security requirements, a reduced amount of lines of code it is achievable having a reliable and scalable enterprise-ready real time data synchronization.

Nevertheless establishing a secure access to Azure Fluid Relay service could still be facilitated by Microsoft with more advanced serverless components. For further reference check my GitHub repositories with the current solution, the Secure Token Service provider and for comparison my former solution.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
FluidFramework and Azure Fluid Relay service

FluidFramework and Azure Fluid Relay service

Back on using Microsoft’s FluidFramework for using synchronized real time data in collaborative applications. Additionally to what I covered in the first post here we will consider more enterprise-ready storage options for handling our backend data.

In fact we are going to replace one single line of code from part I which establishes the client.

const client = new TinyliciousClient();

And yes it’s about replacing one single line with a bunch of code but of course we have to talk about reliability, enterprise-readiness and last not least security. The answer will be the Azure Fluid Relay service which is in public preview right now at the time of writing this post.

Series

Content

Azure Fluid Relay

While in part one our server side component tinylicious was working out of the box we now have to deploy an Azure Fluid Relay service. As for typical Azure resources there’s nothing more than selecting a subscription, resource group and a name.

Create an Azure Fluid Relay

Client access

To establish a connection at first an azure-client package has to be installed.

npm i @fluidframework/azure-client @fluidframework/test-client-utils

For access to the Azure Fluid Relay service a shared key is needed. This can be retrieved from the access keys section.

Insecure

The easiest but not very secure way is to use the InsecureTokenProvider. As needed this will create a Json Web Token (JWT) for access to Azure Fluid Relay service.

export const connectionConfig: AzureClientProps = { connection: {
    tenantId: "34d381d*-****-****-****-*******60f90",
    tokenProvider: new InsecureTokenProvider("c51b27e2881cfc8d8101d0e1dfaea768", { id: userID }),
    orderer: "https://alfred.westeurope.fluidrelay.azure.com",
    storage: "https://historian.westeurope.fluidrelay.azure.com",
}} ;
const client = new AzureClient(connectionConfig);

While in line 2 the Tenant Id from above’s screenshot is used in line 3 it’s the Primary Key as shared secret. A userID is used as a variable as later a real login is established for a different reason. But here also a dummy user could be used.

Azure Token serverless

The disadvantage of the previous alternative is that the shared secret is published within client-side code. The better and more enterprise-ready solution is to retrieve the Json Web Token (JWT) from an Azure Function. So the shared secret is held in the Azure Function server-side.

Except the shared secret the Microsoft tutorial “How to implement the service token provider” handles the other variables as parameters. In this implementation I put some of them to the configuration or even hardcoded such as the tenant ID and the scope. This helps for simplicity reasons but of courses does not enable a more flexible or multi-tenant scenario. The main function code looks like this:

import { AzureFunction, Context, HttpRequest } from "@azure/functions"
import { ScopeType } from "@fluidframework/azure-client";
import { generateToken } from "@fluidframework/azure-service-utils";
import { getSecret } from "./keyVault";
const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
  const tenantId = process.env.TENANTID;
  const documentId = (req.query.documentId || (req.body && req.body.documentId)) as string | undefined;
  const userId = (req.query.userId || (req.body && req.body.userId)) as string;
  const userName = (req.query.userName || (req.body && req.body.userName)) as string;
  const scopes = [ScopeType.DocRead, ScopeType.DocWrite, ScopeType.SummaryWrite]; // "doc:read", "doc:write", "summary:write"
  if (!tenantId) {
    context.res = {
      status: 400,
      body: "No tenantId provided in query params",
    };
    return;
  }
  const key = await getSecret("AzureFluidRelay"); // Name of KeyVault secret
  if (!key) {
    context.res = {
      status: 404,
      body: `No key found for the provided tenantId: ${tenantId}`,
    };
    return;
  }
  let user = { name: userName, id: userId };
  // Will generate the token and returned by an ITokenProvider implementation to use with the AzureClient.
  const token = generateToken(
    tenantId,
    key,
    scopes ?? [ScopeType.DocRead, ScopeType.DocWrite, ScopeType.SummaryWrite],
    documentId,
    user
  );
  context.res = {
    status: 200,
    body: token
  };
};
export default httpTrigger;

For a full solution running at the point of writing, refer to my GitHub repo for this Azure Function. Also as the referred Microsoft tutorial was not up to date and having several errors / inconsistencies at the same point of time.

Testing

As the Azure Function runs anonymously it can simply be tested:
Run your function Url with following query parameters
https://<YourFunctionUrl>.azurewebsites.net/api/FluidTokenProvider?userName=markus&userId=markus@mmsharepoint.onmicrosoft.com&documentId=a0004aeb-272a-4dc2-b28b-51830cef61d7

This might produce a response like the following:

{
  "alg": "HS256",
  "typ": "JWT"
}.{
  "documentId": "a0004aeb-272a-4dc2-b28b-51830cef61d7",
  "scopes": [
    "doc:read",
    "doc:write",
    "summary:write"
  ],
  "tenantId": "34d381d*-****-****-****-*******60f90",
  "user": {
    "name": "markus",
    "id": "markus@mmsharepoint.onmicrosoft.com"
  },
  "iat": 1654865714,
  "exp": 1654869314,
  "ver": "1.0",
  "jti": "b21cd59f-3efa-4cc0-a8fd-6cd61f5429df"
}.[Signature]

Consume it

As now the Azure function is running anonymously it simply can be consumed by our existing client-side code. This looks like the following:

export const connectionConfig: AzureClientProps = { connection: {
    tenantId: process.env.REACT_APP_TENANT_ID!,
    tokenProvider: new AzureFunctionTokenProvider(process.env.REACT_APP_AZURETOKENURL + "/api/FluidTokenProvider", { userId: userID, userName: "Test User" }),
    orderer: process.env.REACT_APP_ORDERER!,
    storage: process.env.REACT_APP_STORAGE!,
}}
const client = new AzureClient(connectionConfig);

In line 3 there is the Azure Function url from above coming from environment variables now. documentId is missing here, this will automatically be added by the corresponding AzureClient methods (getContainer(…) or createContainer() ) as the documentId is the same like the id of the container. userID (and also the optional userName could) is handled like above coming from a user login.

Azure Function and user authentication

Authentication against Azure Fluid Relay service always works with a shared secret. To implement user authentication you can use the workaround that the user authentication works against the Azure function which then will provide shared secret only to authenticated and authorized users. Two things need to be considered for that. At first the Azure Function needs to be configured to only accept calls from authenticated users. Next the client-side call needs to be executed in an authenticated manner. To configure the Azure Function switch to the authentication tab and require authentication while adding Microsoft as an identity provider.

Configure Azure Function authentication
Add Microsoft Azure AD as authentication provider

Next the created corresponding app registration needs to be configured. From the authentication tab click the app registration under Microsoft identity provider:

Microsoft identity provider of Azure Function authentication

What’s needed is a single page application with a corresponding redirect URI. In the given example only with a local test URL:

Single page application platform configuration

Now we can login to our application and use the received identity token to authenticate against our AzureTokenProvider function. Microsoft’s sample AzureTokenProvider does not implement authentication but we can take the open source code and further adjust it. The sample code can be found in Microsoft’s tutorial which simply can be enhanced by additionally accepting an identity token and use that to authenticate against the Azure Function:

export class AzureFunctionTokenProviderSec implements ITokenProvider {
    constructor(
        private readonly azFunctionUrl: string,
        private readonly authToken: string,
        private readonly user?: Pick,        
    ) { }
    public async fetchOrdererToken(tenantId: string, documentId?: string): Promise {
        return {
            jwt: await this.getToken(tenantId, documentId),
        };
    }
    public async fetchStorageToken(tenantId: string, documentId: string): Promise {
        return {
            jwt: await this.getToken(tenantId, documentId),
        };
    }
    private async getToken(tenantId: string, documentId: string | undefined): Promise {
        const response = await axios.get(this.azFunctionUrl, {
            headers: {
                Authorization: `Bearer ${this.authToken}`
            },
            params: {
                tenantId,
                documentId,
                userId: this.user?.userId,
                userName: this.user?.userName,
                additionalDetails: this.user?.additionalDetails,
            },
        });
        return response.data as string;
    }
}

So the difference can be found in line 4 and line 19-21. The constructor accepts an identity token and the getToken function uses it for authentication. Another difference now can be found in the utils class. If the getFluidContainer function receives an identity token It will simply exchange the AzureFunctionTokenProvider.

export async function getFluidContainer(userId: string, authToken?: string): Promise {
  userID = userId;
  if (authToken !== undefined) {
    connectionConfig.connection.tokenProvider = new AzureFunctionTokenProviderSec(process.env.REACT_APP_AZURETOKENURL + "/api/FluidTokenProvider", authToken, { userId: userID, userName: "Test User" });
  }  
  const client = new AzureClient(connectionConfig);
  let containerId: string = window.location.hash.substring(1);
  if (!containerId) {
    containerId = await createContainer(client);
    window.location.hash = containerId;
  }
  const container = await getContainer(client, containerId);
  return container;
};

So how do we get this identity token? In our app there is a specific login component. For this login component I am using MSAL.js 2.0 (npm install @azure/msal-browser) which I already introduced here. Once the user logs in a callback function is called:

const loginUser = async () => {
    const loginResponse = await msalInstance.loginPopup({ scopes:[] });
    props.login(loginResponse.account?.name!, loginResponse.account?.username!, loginResponse.idToken);
};

The callback function now retrieves the userID, userName and the idToken. Having that the fluidContainer can be instantiated:

const login = async (name: string, userName: string, idToken: string) => {
    setUserDisplayName(name);    
    setUserLoggedin(true);
    setUserID(userName);
    const container: IFluidContainer = await getFluidContainer(userName, idToken);
    setFluidContainer(container);
};

Here only a valid user authentication was considered. Of course you can furthermore use Azure AD security group based authorization which I showed client-side here or server-side here.

Azure Fluid Relay management

Of course many containers might get created over time And there might be a need for a proper lifecycle management. Here are some methods with Microsoft Azure CLI for that. The first one is for listing all available containers:

az rest --method get --uri https://management.azure.com/subscriptions/<subscriptionId>/resourcegroups/<resourceGroupName>/providers/Microsoft.FluidRelay/FluidRelayServers/<frsResourceName>/FluidRelayContainers?api-version=<apiVersion>

This might result in an output like this:

[
...
{
      "id": "/subscriptions/*9ba8c0*-****-****-****-**c081cd91**/resourceGroups/DefaultMMsharepoint/providers/Microsoft.FluidRelay/fluidRelayServers/mmdemorelay/fluidRelayContainers/72bb13b9-55e6-4007-b11c-0607c3f94f38",
      "name": "72bb13b9-55e6-4007-b11c-0607c3f94f38",
      "properties": {
        "frsContainerId": "72bb13b9-55e6-4007-b11c-0607c3f94f38",
        "frsTenantId": "34d381d*-****-****-****-*******60f90"
      },
      "resourceGroup": "DefaultMMsharepoint",
      "type": "Microsoft.FluidRelay/fluidRelayServers/fluidRelayContainers"
    }
  ]

That one now can be deleted quite similar to the get command:

az rest --method delete --uri https://management.azure.com/subscriptions/9ba8c0*-****-****-****-**c081cd91**/resourcegroups/DefaultMMsharepoint/providers/Microsoft.FluidRelay/FluidRelayServers/mmDemoRelay/FluidRelayContainers/72bb13b9-55e6-4007-b11c-0607c3f94f3?api-version=2021-08-30-preview

For further reference refer to the Microsoft documentation.

Summary and outlook

The application now looks only slightly different than in the first part. The main difference is, that it only establishes a container after a successful user login. This also works with different users of course. But the rest, especially the real-time data synchronization finally stays the same:

The app before a user login
The app after a user login
The app in action – with different users but still syncing data

While in the first part the client site usage of Microsoft FluidFramework was shown, in this part a more professional storage option with Microsoft Azure Fluid Relay service was described in detail. Especially the necessity for secure authentication was explained with several facets. In my opinion this could be further improved out-of-the-box by Microsoft Azure with serverless components. Currently too much coding still necessary. But remember that we are talking about public preview technology at the time of writing this post.

Next we are going to try to use both described parts, client-side and server-side storage option inside a Microsoft Teams application. Finally for your reference you can meanwhile find both of my code repositories in my GitHub:

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
FluidFramework in a collaborative app (Vote Movies)

FluidFramework in a collaborative app (Vote Movies)

In my last post I showed a teams meeting app where it was a possible to vote for movies. I also showed the challenge you have in such collaborative apps: To sync interactive data collected from the user over several clients.

In this post I want to introduce Microsoft’s Fluid Framework as a solution for that. As a starter let’s concentrate on the very basics using the fluid framework in a standalone application for demo purposes. Later we will introduce enterprise resources and also the integration into Microsoft Teams.

Series

Content

Client and container

The data to be synchronized needs to be part of a container. The container would be synchronized  server-side and therefore a client is needed to access it.

There should be a container per app instance represented by an id. This id is added as a hash to the url here. In Teams later, where an instance represents an installed channel tab or an app installed to a specific meeting, this is handled slightly different on app installation. But here in this stand alone demo it is that easy.

In this little demo app we will only use a small and lightweight tinylicious component. More enterprise ready resources such as Azure Fluid Relay will be covered in one of my next posts.

In Code this looks like the following:

import { TinyliciousClient } from '@fluidframework/tinylicious-client';
import { IFluidContainer, SharedMap } from 'fluid-framework';
import { FluidVoting } from './components/FluidVoting';
const client = new TinyliciousClient();
const containerSchema = {
  initialObjects: { sharedVotes: SharedMap }
};
const createContainer = async () => {
  const { container } = await client.createContainer(containerSchema);
  const containerId = await container.attach();
  // Initialize votes
  const sharedVotes = container.initialObjects.sharedVotes as SharedMap;
  sharedVotes.set("votes1", 0);
  sharedVotes.set("votes2", 0);
  sharedVotes.set("votes3", 0);
  return containerId;
};
const getContainer = async (containerId: string) => {
  const { container } = await client.getContainer(containerId, containerSchema);
  return container;
}
const getFluidContainer = async () => {
  let containerId: string = window.location.hash.substring(1);
  if (!containerId) {
    containerId = await createContainer();
    window.location.hash = containerId;
  }
  const container = await getContainer(containerId);
  return container;
};
const App = () => {
  const [fluidContainer, setFluidContainer] = React.useState<IFluidContainer>();
  const [fluidContainerMap, setFluidContainerMap] = React.useState<SharedMap>();
  
  React.useEffect(() => {
    getFluidContainer()
     .then(c => setFluidContainer(c));
  }, []);
  React.useEffect(() => {
    if (fluidContainer !== undefined) {
      const sharedVotes = fluidContainer.initialObjects.sharedVotes as SharedMap;
      setFluidContainerMap(sharedVotes);
    }
  }, [fluidContainer]);
  if (fluidContainerMap !== undefined) {
    return (
      <FluidVoting votingMap={fluidContainerMap!} />
    );
  }
  else {
    return (
      <div >Loading votings...</div>
    );
  }
};
export default App;

If there is already an app instance or not is detected by the location hash value. If there is access to the container is directly established if not the container gets created.

Fluid component

The UI component dealing with the shared values is separated. As properties it receives the shared map from the parent component.

The shared map provides the given properties for the instantiation of the react state variables. Then we have a video elements and the corresponding vote buttons. On the vote buttons there is an increment method. Once a button is clicked the number of votes is incremented by one and then the synchronization process takes place.

The synchronization process from button click (Vote) till sync in any other client will work with the following steps:

  1. .set the new value to the shared map (line 28, 32, 36)
  2. Fluid Framework will automatically sync this server
  3. “ValueChanged” event is fired at any client that subscribed to (line 9)
  4. Custom event receiver should update React state variable (line 10-14)

In code this looks like the following:

export const FluidVoting = (props: IFluidVotingProps) => {
  const [votes1, setVotes1] = React.useState<number>(props.votingMap.get("votes1")!);
  const [votes2, setVotes2] = React.useState<number>(props.votingMap.get("votes2")!);
  const [votes3, setVotes3] = React.useState<number>(props.votingMap.get("votes3")!);
  const videoSrc1 = `https://ia904503.us.archive.org/5/items/windows-7-sample-video/Wildlife.mp4`;
  const videoSrc2 = `https://adaptivecardsblob.blob.core.windows.net/assets/AdaptiveCardsOverviewVideo.mp4`;
  const videoSrc3 = `https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/Sample-MP4-Video-File-for-Testing.mp4`;
  React.useEffect(() => {
    const updateVotes = () => {
      setVotes1(props.votingMap.get("votes1")!);
      setVotes2(props.votingMap.get("votes2")!);
      setVotes3(props.votingMap.get("votes3")!);
    };
    props.votingMap.on("valueChanged", updateVotes);
    return () => {
      props.votingMap.off("valueChanged", updateVotes);
    };
  });
    return (
        <div className='appContainer' >
          <div className="videoFrame">
            <video src={videoSrc1} controls width={260}></video>
          </div>
          <button onClick={() => { props.votingMap.set("votes1", votes1! + 1); }}>Vote Movie 1</button>
          <div className="videoFrame">
            <video src={videoSrc2} controls width={260}></video>
          </div>
          <button onClick={() => { props.votingMap.set("votes2", votes2! + 1); }}>Vote Movie 2</button>
          <div className="videoFrame">
            <video src={videoSrc3} controls width={260}></video>
          </div>
          <button onClick={() => { props.votingMap.set("votes3", votes3! + 1); }}>Vote Movie 3</button>
          <div>
              <span className="votesResult"><text>{`Votes Movie 1: ${votes1}`} </text></span>
              <span className="votesResult"><text>{`Votes Movie 2: ${votes2}`} </text></span>
              <span className="votesResult"><text>{`Votes Movie 3: ${votes3}`} </text></span>
          </div>
        </div>
      );
};

Testing

To run the app it’s not only necessary to start the app itself. Additionally the lightweight tinylicious server-side component needs to be started. So it’s necessary to fire up two consoles. In the first start the tinylicious server:

npx tinylicious

In the second start the app itself:

npm run start

Now after start the application is also opened in default browser and the url switches after a short moment from http://localhost:3000 to something like http://localhost:3000/#ae170b55-b402-4574-bbd5-a63abdcb1642

When you copy that url and open it in a different browser (tab) you can see the same votes and see them syncing while clicking the Vote buttons in the 1st or 2nd browser (tab). If you open the app again without the hash in the url (http://localhost:3000) you will see fresh values, not syncing and a new hash attached to the url, that is a new app instance with a new fluid container.

One app instance syncing in two browser clients look like the following picture:

Fluid Framework in action – Two browsers in-sync

As always the whole app’s source code can be found in my GitHub repository. In a follow up post I might show you how to replace the more demo and dev environment targeted tinylicious component by Azure Fluid Relay which is in Public Preview currently. So stay tuned.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Teams Meeting apps – A sample for in-Meeting experience and stageView (Vote Movies)

Teams Meeting apps – A sample for in-Meeting experience and stageView (Vote Movies)

I already gave a basic explanation how to establish a Teams meeting app to be shown / shared in stageView. This time I want to show a real sample that shows the capabilities but also the challenges of those implementations. Assume during a meeting users shall give some feedback / input individually and based on the results something shall happen / to be shown for all participants at the same time. That said, let the users vote for 1 / 3 available videos and show the most voted on stageView finally.

Content

Setup solution

To setup this solution a teams tab solution is needed with some specific settings in the manifest. I already explained this in detail in a previous post but here the basics once again. In yeoman generator for Teams the setup looks like this:

yo teams for stageView Teams Meeting tab

Having the solution the manifest needs to be adapted like this:

"configurableTabs": [
    {
      "configurationUrl": "https://{{PUBLIC_HOSTNAME}}/voteMovieTab/config.html?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
      "canUpdateConfiguration": true,
      "scopes": [
        "groupchat"
      ],
      "context": [
        "meetingChatTab",
        "meetingDetailsTab",
        "meetingSidePanel",
        "meetingStage"
      ],
      "meetingSurfaces": [
        "sidePanel",
        "stage"
      ]
    }
  ],

Under context meetingChatTab and meetingDetailsTab are not necessary. I simply left them to see something once I add the app to the meeting in pre-Meeting experience as well. For the scope groupChat is essential to be used in Meeting apps. For the in-Meeting experience meetingSidePanel is the relavnt context while for stageView meetingStage is necessary.

Configuration part

The movies or videos to vote shall be added via Teams tab configuration by the user adding the app to the meeting (or can be changed later once again). I recently wrote about the configuration of a Teams tab (there is no real difference with a normal channel tab to a Teams meeting app) in detail but for a complete overview here a quick look into the main code part:

export const VoteMovieTabConfig = () => {
    const [{ inTeams, theme, context }] = useTeams({});
    const [movie1, setMovie1] = useState<string>();
    const [movie2, setMovie2] = useState<string>();
    const [movie3, setMovie3] = useState<string>();
    const movieRef1 = useRef<string>("");
    const movieRef2 = useRef<string>("");
    const movieRef3 = useRef<string>("");
    const meetingID = useRef<string>("");
    const entityId = useRef("");
    const loadConfig = async (meeting: string) => {
        Axios.get(`https://${process.env.PUBLIC_HOSTNAME}/api/config/${meeting}`).then((response) => {
                const config = response.data;
                setMovie1(config.movie1url);
                setMovie2(config.movie2url);
                setMovie3(config.movie3url);
            });
    };
    
    const onSaveHandler = (saveEvent: microsoftTeams.settings.SaveEvent) => {
        const host = "https://" + window.location.host;
        microsoftTeams.settings.setSettings({
            contentUrl: host + "/voteMovieTab/?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
            websiteUrl: host + "/voteMovieTab/?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
            suggestedDisplayName: "Vote Movie",
            removeUrl: host + "/voteMovieTab/remove.html?theme={theme}",
            entityId: entityId.current
        });
        saveConfig();
        saveEvent.notifySuccess();
    };
    const saveConfig = () => {
        Axios.post(`https://${process.env.PUBLIC_HOSTNAME}/api/config/${meetingID.current}`,
                    { config: { movie1url: movieRef1.current, movie2url: movieRef2.current, movie3url: movieRef3.current }});
    };
    useEffect(() => {
        movieRef1.current = movie1!;
        movieRef2.current = movie2!;
        movieRef3.current = movie3!;
    }, [movie1, movie2, movie3]);
    useEffect(() => {
        if (context) {            
            let meeting = "";
            if (context.meetingId === "") {
                meeting = "alias";
            }
            else {
                meeting = context.meetingId!;
            }
            meetingID.current = meeting;
            loadConfig(meeting);
            microsoftTeams.settings.registerOnSaveHandler(onSaveHandler);
            microsoftTeams.settings.setValidityState(true);
            microsoftTeams.appInitialization.notifySuccess();
        }
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [context]);
    return (
        <Provider theme={theme}>
            <Flex fill={true}>
                <Flex.Item>
                    <div>
                        <Header content="Configure your tab" />
                        <Input
                            label="Movie 1"
                            placeholder="Enter a url for movie 1"
                            fluid
                            clearable
                            value={movie1}
                            onChange={(e, data) => {
                                if (data) {
                                    setMovie1(data.value);
                                }
                            }}
                            required />
                        <Input
                            label="Movie 2"
                            placeholder="Enter a url for movie 2"
                            fluid
                            clearable
                            value={movie2}
                            onChange={(e, data) => {
                                if (data) {
                                    setMovie2(data.value);
                                }
                            }}
                            required />
                        <Input
                            label="Movie 3"
                            placeholder="Enter a url for movie 3"
                            fluid
                            clearable
                            value={movie3}
                            onChange={(e, data) => {
                                if (data) {
                                    setMovie3(data.value);
                                }
                            }}
                            required />
                    </div>
                </Flex.Item>
            </Flex>
        </Provider>
    );

There are three input fields for the movie urls. Each of them hold a state variable (movie1, movie2, movie2). But additionally those 3 state variables are synced with 3 ref variables by the useEffect ... [movie1, movie2, movie3] hook. The reason for this is, the onSaveHandler also needs to be registered at the very beginning (on context available) and therefore any later state change won’t be reflected anymore. So the onSaveHandler and the saveConfig finally act on those ref variables and store them to the configuration store.

The configuration pop up

Vote movies (in-Meeting experience)

When the app is started in a meeting it occurs as side panel similar like the chat or participant list. And it’s opened individually. So if one user clicks to open others won’t still see it.

“Vote Movie” app as inMeeting in sidePanel

The three configured movies are rendered and the user can click one of three vote buttons once. But not more because once voted the user is not allowed to do this one more time. Additionally any user who opens the app will see the current voting results at the bottom (of course you could hide that for users that did not vote, yet). And here are the challenges now, there is a need to check if a user already voted but also the need to “sync” the results because if a user has the app open the results should update while other users vote in the meantime. While I was establishing another sample around “voting” or better: give feedback with adaptive cards and universal action model together with a bot those adaptive cards cannot be used in a Teams tab unfortunately so here this needs to be developed “manually” (I might come up with different approaches in the near future!)

Let’s first have a look on the UI part:

    useEffect(() => {
        Axios.get(`https://${process.env.PUBLIC_HOSTNAME}/api/config/${props.meetingID}`).then((response) => {
            const config = response.data;
            setMovie1(config.movie1url);
            setMovie2(config.movie2url);
            setMovie3(config.movie3url);
        });
        loadVotes();
        evalVotable();
    }, []);
    useEffect(() => {
        video1Ref!.current!.load();
    }, [movie1]);
    useEffect(() => {
        video2Ref!.current!.load();
    }, [movie2]);
    useEffect(() => {
        video3Ref!.current!.load();
    }, [movie3]);
return (
        <Provider theme={props.theme}>
            <Flex fill={true} column styles={{
                padding: ".8rem 0 .8rem .5rem"
            }}>
                <Flex.Item>
                    <Header content="Vote for your movie" />
                </Flex.Item>
                <Flex.Item>
                    <div className="panelSize">
                        <div className="videoFrame">
                            <video ref={video1Ref} controls width={260}>
                                <source src={movie1} type="video/mp4"></source>
                            </video>
                        </div>
                        {votable &&
                        <div>
                            <Button className="voteBtn" onClick={() => vote(1)}>Vote Movie 1</Button>
                        </div>}
                        <div className="videoFrame">
                            <video ref={video2Ref} controls width={260}>
                                <source src={movie2}></source>
                            </video>
                        </div>
                        {votable &&
                        <div>
                            <Button className="voteBtn" onClick={() => vote(2)}>Vote Movie 2</Button>
                        </div>}
                        <div className="videoFrame">
                            <video ref={video3Ref} controls width={260}>
                                <source src={movie3}></source>
                            </video>
                        </div>
                        {votable &&
                        <div>
                            <Button className="voteBtn" onClick={() => vote(3)}>Vote Movie 3</Button>
                        </div>}                        
                    </div>
                </Flex.Item>
                <Flex.Item styles={{
                    padding: ".8rem 0 .8rem .5rem"
                }}>
                    <div>
                        <span className="votesResult"><Text size="smaller" content={`Votes Movie 1: ${votes?.votes1}`} /></span>
                        <span className="votesResult"><Text size="smaller" content={`Votes Movie 2: ${votes?.votes2}`} /></span>
                        <span className="votesResult"><Text size="smaller" content={`Votes Movie 3: ${votes?.votes3}`} /></span>
                    </div>
                </Flex.Item>
            </Flex>
        </Provider>
    );

There are three <video /> elements followed by a button which is shown under the condition votable. At the very bottom the current votes are shown with <Text size="smaller" /> . Let’s see the functionality having a look at the first useEffect hook. On mount ([]) the configured Urls of the movies are retrieved and once they are written to the state another useEffect hook loads the videos. This is essential while dealing with <video /> elements where their src is set later at runtime!

Next there is a need to check if the user already voted but also the current votes need to be loaded and kept up to date.

const loadVotes = async () => {
        Axios.get(`https://${process.env.PUBLIC_HOSTNAME}/api/votesnc/${props.meetingID}`).then((response) => {
            setVotes(response.data);
            setTimeout(() => loadVotes(), 5000);
        });
    };

The votes are not only loaded from the backend service but also via setTimeout the load is repeated every 5s which will keep the votes up-to-date if a user keeps the side panel open for a while and other users still vote.

const vote = async (movie: number) => {        
        const response = await Axios.post(`https://${process.env.PUBLIC_HOSTNAME}/api/votenc/${props.meetingID}/${movie}/${props.userID}`);
        evalVotable();
};
const evalVotable = async () => {
        const userID = props.userID;
        Axios.get(`https://${process.env.PUBLIC_HOSTNAME}/api/votable/${props.meetingID}/${userID}`).then((response) => {
            const config = response.data;
            setVotable(response.data);
        });
    };

While storing the vote of a user to the backend also it’s userID is stored. Later it can be retrieved so it’s ensured the user cannot vote a 2nd time.

Show most voted movie (stageView)

Once a user clicks on the share button inside the sidePanel the same app is shared in stageView. There is also a programmatic option inside the Teams SDK but I didn’t cover it yet.

A share button in a sidePanel app

To not show the same content than in sidePanel or even in preMeeting experience there is the frameContext to distinct between the different options. I simply do that in the root component and based on current frameContext I render different components:

if (context.frameContext! === microsoftTeams.FrameContexts.meetingStage) {
                setInStageView(true);
}
else {
                setInStageView(false);
}
...
return (
        <div>
            {context && meetingId && inStageView && <VoteMovieResults meetingID={meetingId!} theme={theme} />}
            {context && meetingId && !inStageView && <VoteMovieVoting userID={context?.userObjectId!} meetingID={meetingId!} theme={theme} />}
        </div>
);

So if inStageView the VoteMovieResults component is rendered otherwise the VoteMovieVoting.

export const VoteMovieResults: React.FC<IVoteMovieResultsProps> = (props) => {
const loadVotes = async () => {
        Axios.get(`https://${process.env.PUBLIC_HOSTNAME}/api/votesnc/${props.meetingID}`).then((response) => {
            setVotes(response.data);
            getHighestVote(response.data);
        });
    };
    
    const getHighestVote = (votes: IResults) => {
        const votes1: number = parseInt(votes?.votes1!);
        const votes2: number = parseInt(votes?.votes2!);
        const votes3: number = parseInt(votes?.votes3!);
        if (votes1 >= votes2 && votes1 >= votes3) { // If voted equal, show movie 1 per default
            setVotedMovie(movie1!);
        }
        if (votes2 > votes1 && votes2 >= votes3) {
            setVotedMovie(movie2!);
        }
        if (votes3 > votes2 && votes3 > votes1) {
            setVotedMovie(movie3!);
        }
    };
....return (
        <Provider theme={props.theme}>
            <Flex fill={true} column styles={{
                padding: ".8rem 0 .8rem .5rem"
            }}>
                <Flex.Item>
                    <Header content="Watch most voted video" />
                </Flex.Item>
                <Flex.Item>
                    <div>
                        <div>
                            <video ref={videoRef} width={640} autoPlay>
                                <source src={votedMovie} type="video/mp4"></source>
                            </video>
                        </div>                    
                    </div>
                </Flex.Item>
                <Flex.Item styles={{
                    padding: ".8rem 0 .8rem .5rem"
                }}>
                    <div>
                        <Text size="smaller" content={`Votes Movie 1: ${votes?.votes1}`} />
                        <Text size="smaller" content={`Votes Movie 2: ${votes?.votes2}`} />
                        <Text size="smaller" content={`Votes Movie 3: ${votes?.votes3}`} />
                    </div>
                </Flex.Item>
            </Flex>
        </Provider>
    );

Beyond the similarities, such as loading the video urls from config and pre-loading the video to be shown furthermore the votes are loaded. Next the highest vote is detected and based on the result the highest voted video url is set to the state. Afterwards nothing more than that video plus the results are rendered quite the same way than in Voting. The only difference of course is the layout as in stageView there is much more space available than in sidePanel.

Showing most voted video in stageView

Backend service with node cache

As you can already guess from previous sections there is the need to store the votes and user ids that voted. For simplicity reasons I do not introduce any additional storage option resources but I simply use node cache for this. Not only for demo purposes this is sufficient here as the results are normally only needed during meeting runtime and would only get cleared if the app (in Azure app service for instance) would be restarted. If you need more details on node cache Elio Struyf has a great post on this. If you need a more robust and longer available solution I‘d prefer a Azure DB solution as I would expect lots of meetings and users voting inside…

First there is the need to store the votes. The backend router retrieves a post with meetingID, userID and number of movie (1, 2 or 3):

/

router.post(
        "/votenc/:meetingID/:movie/:userID",
        
        async (req: any, res: express.Response, next: express.NextFunction) => {
            const meetingID: any = req.params.meetingID;
            const movie: any = req.params.movie;
            const userID: any = req.params.userID;
            try {
                const movieVotes = nodeCache.get(`${meetingID}_${movie}`) as string;
                let newMovieVotes = parseInt(movieVotes);
                if (Number.isNaN(newMovieVotes)) {
                    newMovieVotes = 1;
                }
                else {
                    newMovieVotes++;
                }
                nodeCache.set(`${meetingID}_${movie}`, newMovieVotes);
                let votedUsers = nodeCache.get(`${meetingID}_votedUsers`) as string;
                votedUsers += `;${userID}`;
                nodeCache.set(`${meetingID}_votedUsers`, votedUsers);
                res.end("OK");
            }
            catch (ex) {
            }
    });

Once the values are retrieved any existing values for that meeting need to be loaded and if there are already votes for that movie they will be incremented by +1 or if not instantiated with 1. Finally the votes will be persisted in node cache with the constructed key ${meetingID}_${movie}.
Next also the userID is persisted so it can be retrieved at any time that this users already voted.

The second need is simply to return all votes per meeting which is called frequently as already shown above. As from previous snippet partially known the code is pretty straightforward:

router.get(
        "/votesnc/:meetingID",
        async (req: any, res: express.Response, next: express.NextFunction) => {
            const meetingID: any = req.params.meetingID;
            const voted1 = nodeCache.get(`${meetingID}_1`) as string;
            const voted2 = nodeCache.get(`${meetingID}_2`) as string;
            const voted3 = nodeCache.get(`${meetingID}_3`) as string;
            const results: IResults = {
                votes1: voted1?voted1:"0",
                votes2: voted2?voted2:"0",
                votes3: voted3?voted3:"0"
            };
            res.json(results);
    
    });
    router.get(
        "/votable/:meetingID/:userID",
        async (req: any, res: express.Response, next: express.NextFunction) => {
            const meetingID: any = req.params.meetingID;
            const userID: any = req.params.userID;
            let votedUsers = nodeCache.get(`${meetingID}_votedUsers`) as string;
            if (votedUsers && votedUsers.indexOf(userID) > -1) {
                res.json(false);
            }
            else {
                res.json(true);
            }
    });

And the 2nd “get” in this snippet simply checks if the given userID is already persisted as a user that voted or not and returns true or false.

Summary and next

This little sample (inspired by a famous TV event in Germany during my childhood in the early 80s) shall demonstrate the chances and capabilities that are available with Teams meeting apps and especially the so called stageView. One big challenge was also shown: Sharing something normally means sharing “the same for all” but combined with individual interactions (here simple voting) there is a clear need for synchronization and keeping everything up to date. Here I did it manually but there might be more comfortable options such as fluid framework/loop components 🤔

Stay tuned for more on this interesting topic and for further reference checkout the whole solution in my GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Restrict calls from SPFx in(side) Azure Functions

Restrict calls from SPFx in(side) Azure Functions

Some time ago I wrote a post how to restrict calls to Azure Functions inside SPFx. I therefore analyzed the token if it contains a specific group the user is member of. As this happened client side already it was directly able to disable a button for instance. On the opposite the well known webApiPermissionRequests to allow the app itself to call a 3rd party Api, that is the Azure Function, are regularly granted tenant wide. So a restriction inside “your” webpart is no security guarantee that no one else uses the Azure Function. To prevent this the same way, the token validation needs to take place backend (,too,) inside the Azure Function. This post shows how to achieve that.

The Azure Function is created the same way as in the previous post. Therefore here you find only the basics and important points.

  • Create an Azure Function
  • Create Environment variables like in local – Sample.settings.json
  • Enable CORS by adding your SharePoint tenant url
  • Establish Microsoft Authentication
  • Create a “speaking” AppIDUri for the app of Authentication Provider https://<Your Azure Subscription Tenant>.onmicrosoft.com/<YourAppID>

Finally edit the Identity Provider by removing the Issuer Url (in case of multi-tenant) and add your AppIDUri to the “Allowed token audiences”


Edit identity provider of Azure function

And the most important thing is to edit the app of your identity provider so the user token on authentication includes group memberships:

Configure app registration of your Azure function
Token configuration of the app registration – Add Groups claim

The code

First dependency injection is added to the Azure Function by adding several packages (Microsoft.Azure.Functions.Extensions, Microsoft.Azure.Functions.Extensions, Microsoft.Azure.Functions.Extensions) and adding a Startup.cs.

[assembly: FunctionsStartup(typeof(ResourceSpecificSPO.Startup))]
namespace ResourceSpecificSPO
{
  public class Startup : FunctionsStartup
  {
    public override void Configure(IFunctionsHostBuilder builder)
    {
      var config = builder.GetContext().Configuration;
      var appConfig = new CustomSettings();
      config.Bind(appConfig);

      builder.Services.AddSingleton(appConfig);
      builder.Services.AddScoped<controller.TokenValidation>();
    }

  }
}

Two things happen here. First the app configuration is bound to a Singleton and made available that way. Next a custom TokenValidation is added as a service.

Then comes the Azure Function class, first the intro:

 public class WriteListItem
  {
    private readonly controller.TokenValidation tokenValidator;
    private CustomSettings appConfig;

    public WriteListItem(CustomSettings appCnfg,
            controller.TokenValidation tknValidator)
    {
      appConfig = appCnfg;
      tokenValidator = tknValidator;
    }

    [FunctionName("WriteListItem")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequestMessage req, ILogger log)
    {

Here it is no static class anymore as per default but now it also has a constructor receiving the app configuration and our service tokenValidator. Inside the function let’s only look at the token validation:

try
      {
        string authHeader = req.Headers.Authorization.Parameter;
        
        ClaimsPrincipal cp = await tokenValidator.ValidateTokenAsync(authHeader);
        if (cp == null)
        {
          // return new ForbidResult();
          log.LogError("Execution is forbidden as user is not member of group: " + appConfig.SecurityGroupID);
        }
        JWTSecurityToken jt = await tokenValidator.AnalyzeTokenAsync(authHeader);
        foreach(Claim c in jt.Claims)
        {
          log.LogInformation(c.Type + " : " + c.Value);
        }
        var roleClaims = y.Claims.Where(c => c.Type.ToLower() == "groups");
        if (!roleClaims.Any(c => c.Value.ToLower() == appConfig.SecurityGroupID.ToLower()))
        {
          return new ForbidResult();
        }
      }
      catch (Exception ex)
      {
        log.LogError(ex.Message);
      }

The code shows two alternatives in parallel. First a ClaimsPrincipal is gathered from the token with a “real” token validation (to be shown below in this post, but not only verifying specific group membership). If something goes wrong no ClaimsPrincipal but null is returned and error could be logged followed by a 403 potentially as result.

Afterwards as an alternative the token is simply analyzed while it’s returned as a simple JWTSecurityToken object. Its properties as Claims are iterated looking for a specific groupID. If this is not found a 403 can be returned for instance.

Last not least the TokenValidator class needs to be investigated:

public class TokenValidation
  {
    private CustomSettings appConfig;
    private const string scopeType = @"http://schemas.microsoft.com/identity/claims/scope";
    private ConfigurationManager<OpenIdConnectConfiguration> _configurationManager;
    private ClaimsPrincipal _claimsPrincipal;

    private string _wellKnownEndpoint = string.Empty;
    private string _tenantId = string.Empty;
    private string _audience = string.Empty;
    private string _instance = string.Empty;
    private string _requiredScope = "user_impersonation";

    public TokenValidation(CustomSettings appCnfg)
    {
      appConfig = appCnfg;
      _tenantId = appConfig.TenantId;
      _audience = appConfig.Audience;
      _instance = appConfig.Instance;
      // _wellKnownEndpoint = $"{_instance}{_tenantId}/v2.0/.well-known/openid-configuration";
      _wellKnownEndpoint = $"{_instance}common/.well-known/openid-configuration";      
    }

    public async Task<ClaimsPrincipal> ValidateTokenAsync(string authorizationHeader)
    {
      if (string.IsNullOrEmpty(authorizationHeader))
      {
        return null;
      }

      var oidcWellknownEndpoints = await GetOIDCWellknownConfiguration();

      var tokenValidator = new JwtSecurityTokenHandler();

      var validationParameters = new TokenValidationParameters
      {
        RequireSignedTokens = false,
        ValidAudience = _audience,
        ValidateAudience = true,
        ValidateIssuer = true,
        ValidateIssuerSigningKey = false,
        ValidateLifetime = true,
        IssuerSigningKeys = oidcWellknownEndpoints.SigningKeys,
        ValidIssuer = oidcWellknownEndpoints.Issuer.Replace("{tenantid}", _tenantId)
      };

      try
      {
        SecurityToken securityToken;
        _claimsPrincipal = tokenValidator.ValidateToken(authorizationHeader, validationParameters, out securityToken);

        if (IsScopeValid(_requiredScope))
        {
          if (isGroupMember(appConfig.SecurityGroupID))
          {
            return _claimsPrincipal;
          }
        }

        return null;
      }
      catch (Exception ex)
      {
        throw ex;
      }
    }
...
  }

First let’s have a look at the start and next let’s investigate the other methods one by one. In the constructor some app config values are retrieved and an endpoint url is built. The necessity is explained a bit later when it’s used in another method.

In the ValidateTokenAsync some ValidationParameters are built and the token is validated based on that. After that some custom checks are done validating a valid scope (“user_impersonation”) and if the required group membership exists.

First helper method is for the token validation GetOIDCWellknownConfiguration. Any OIDC authority should offer a well known oidc configuration. This is requested here from the constructed wellKnownEndpoint. And the result is partially used in our ValidationParameters.

private async Task<OpenIdConnectConfiguration> GetOIDCWellknownConfiguration()
    {
        _configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
           _wellKnownEndpoint, new OpenIdConnectConfigurationRetriever());

        return await _configurationManager.GetConfigurationAsync();
    }

That constructed url used here is https://login.microsoftonline.com/common/.well-known/openid-configuration which means it is multi-tenant and for version 1. Alternatively you could use https://login.microsoftonline.com/<tenantID>/v2.0/.well-known/openid-configuration or a mix to receive tenant-specific values and for v2.0. But be aware what kind of tokens you receive. I detected v1.0 tokens used by SPFx on my Azure Function as I had an issuer like https://sts.windows.net/{tenantid}/

To verify it, there is also the necessity to replace the {tenantid} by the following line inside the ValidationParameters: ValidIssuer = oidcWellknownEndpoints.Issuer.Replace("{tenantid}", _tenantId)
I encourage you to simply try this Urls in browser and have a look what’s returned.

Maybe I should also add further packages necessary for the specific token validation and further implementation:

<PackageReference Include="Microsoft.Identity.Client" Version="4.35.1" />
<PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.12.0" />

I found out that for System.IdentityModel.Tokens.Jwt any newer version than 6.12.0 was not able to execute _configurationManager.GetConfigurationAsync(). This is why I had to use that version. Any hint to solve this would be more than welcome.
[Error: IDX20803: Unable to obtain configuration from: '[PII of type 'System.String' is hidden. For more details, see https://aka.ms/IdentityModel/PII.] ]

After the token is validated the validScope can be checked by:

private bool IsScopeValid(string scopeName)
    {
      if (_claimsPrincipal == null)
      {
        return false;
      }

      var scopeClaim = _claimsPrincipal.HasClaim(x => x.Type == scopeType)
          ? _claimsPrincipal.Claims.First(x => x.Type == scopeType).Value
          : string.Empty;

      if (string.IsNullOrEmpty(scopeClaim))
      {
        return false;
      }

      if (!scopeClaim.Equals(scopeName, StringComparison.OrdinalIgnoreCase))
      {
        return false;
      }
      return true;
    }

First the general existence of a scope claim is verified followed by containing the right scope given as parameter. Next and final comes the check for the group membership:

private bool isGroupMember(string groupID)
    {
      var roleClaims = _claimsPrincipal.Claims.Where(c => c.Type.ToLower() == "groups");
      if (roleClaims.Any(c => c.Value.ToLower() == groupID.ToLower()))
      {
        return true;
      }
      return false;
    }

For better clarity I omit short notation and first extract all Claims of type “groups” and next and finally verify if one has the required groupID as value.

In parallel there was also the rudimentary token analysis method in this class which is finally shown:

public async Task<JwtSecurityToken> AnalyzeTokenAsync(string authorizationHeader)
    {
      if (string.IsNullOrEmpty(authorizationHeader))
      {
        return null;
      }
      var tokenValidator = new JwtSecurityTokenHandler();
      var token = tokenValidator.ReadJwtToken(authorizationHeader);
      return token;
    }

This method simply takes the token string from the header and builds a JWTSecurityToken out of it. No further validation takes place here. On return then the Claims are checked for the given GroupID but that happens in the Azure Function itself shown above, already.

I hope this little explanation helps to put more security in your Azure Functions. I only tried to concentrate on those things I feel most important but for your whole reference refer to the full solution available in my GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Configure Teams Tab applications

Configure Teams Tab applications

Once upon a time I already wrote about the configuration of a Teams application. I used a messaging extension as a sample application but was furthermore concentrating on the storage options of the configuration values. In a Teams Tab application the considerations for storage options are nearly the same but the handling in the app itself is slightly different as there is a different installation functionality. This will be the focus in this post.

Once we setup a Teams Tab application with the yeoman generator for Teams there is already a configuration page setup by default. It consists of a HTML page and the corresponding React JSX component:

The “usage” of this page is defined inside the app’s manifest:

"configurableTabs": [
    {
      "configurationUrl": "https://{{PUBLIC_HOSTNAME}}/voteMovieTab/config.html?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
      "canUpdateConfiguration": true,
      "scopes": [
        "team",
        "groupchat"
      ]
    }

So if canUpdateConfiguration is set to true on installation of the app in the context of a channel, a meeting or a group chat for instance, the page linked under configurationUrl is rendered. And it is also rendered once a user wants to update the configuration while clicking on “Settings” in the context of a tab:

(Re-)Opening Teams Tab settings

So the configuration page functionally is responsible for 1-2 steps at least:

  1. Installing the app in the current context with its Url(s), displayName e.g. [Mandatory]
  2. Storing user (and context) specific configuration values valid in the current instantiation scenario (might differ from channel to channel, meeting to meeting e.g.) [Optionally]

Step 1 is basically handled the following way:

const onSaveHandler = (saveEvent: microsoftTeams.settings.SaveEvent) => {
        const host = "https://" + window.location.host;
        microsoftTeams.settings.setSettings({
            contentUrl: host + "/voteMovieTab/?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
            websiteUrl: host + "/voteMovieTab/?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
            suggestedDisplayName: "Vote Movie",
            removeUrl: host + "/voteMovieTab/remove.html?theme={theme}",
            entityId: entityId.current
        });
        // Custom code might go here ...
        saveEvent.notifySuccess();
    };

useEffect(() => {
        if (context) {            
            // Custom code might go here ...
            microsoftTeams.settings.registerOnSaveHandler(onSaveHandler);
            microsoftTeams.settings.setValidityState(true);
            microsoftTeams.appInitialization.notifySuccess();
        }
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [context]);

So what happens is that in case of an existing Teams context a saveHandler is registered. This will be executed on the standard Save button (which is added automatically to the page and not explicitly coded in the JSX (nor HTML) file). Inside the onSaveHandler the microsoftTeams.settings.setSettings function is responsible for the correct app installation.

Storing custom configuration values now should happen in the same context. All you need is:

  • Custom controls in your JSX for input of the config values
  • Correct state / ref handling of the values with your onSaveHandler
    • Load potentially already existing values
    • Have the latest entered value available “onSave”
  • A service writing the values to your storage option
    • Azure App configuration related
    • SharePoint related
    • ….

A full config component might look like this now:

export const VoteMovieTabConfig = () => {
    const [{ inTeams, theme, context }] = useTeams({});
    const [movie1, setMovie1] = useState<string>();
    const [movie2, setMovie2] = useState<string>();
    const [movie3, setMovie3] = useState<string>();
    const movieRef1 = useRef<string>("");
    const movieRef2 = useRef<string>("");
    const movieRef3 = useRef<string>("");
    const meetingID = useRef<string>("");
    const entityId = useRef("");

    const loadConfig = async (meeting: string) => {
        Axios.get(`https://${process.env.PUBLIC_HOSTNAME}/api/config/${meeting}`).then((response) => {
                const config = response.data;
                setMovie1(config.movie1url);
                setMovie2(config.movie2url);
                setMovie3(config.movie3url);
            });
    };
    
    const onSaveHandler = (saveEvent: microsoftTeams.settings.SaveEvent) => {
        const host = "https://" + window.location.host;
        microsoftTeams.settings.setSettings({
            contentUrl: host + "/voteMovieTab/?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
            websiteUrl: host + "/voteMovieTab/?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
            suggestedDisplayName: "Vote Movie",
            removeUrl: host + "/voteMovieTab/remove.html?theme={theme}",
            entityId: entityId.current
        });
        saveConfig();
        saveEvent.notifySuccess();
    };

    const saveConfig = () => {
        Axios.post(`https://${process.env.PUBLIC_HOSTNAME}/api/config/${meetingID.current}`,
                    { config: { movie1url: movieRef1.current, movie2url: movieRef2.current, movie3url: movieRef3.current }});
    };

    useEffect(() => {
        movieRef1.current = movie1!;
        movieRef2.current = movie2!;
        movieRef3.current = movie3!;
    }, [movie1, movie2, movie3]);
    useEffect(() => {
        if (context) {            
            const meeting = context.meetingId!;            
            meetingID.current = meeting;
            loadConfig(meeting);
            microsoftTeams.settings.registerOnSaveHandler(onSaveHandler);
            microsoftTeams.settings.setValidityState(true);
            microsoftTeams.appInitialization.notifySuccess();
        }
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [context]);

    return (
        <Provider theme={theme}>
            <Flex fill={true}>
                <Flex.Item>
                    <div>
                        <Header content="Configure your tab" />
                        <Input
                            label="Movie 1"
                            placeholder="Enter a url for movie 1"
                            fluid
                            clearable
                            value={movie1}
                            onChange={(e, data) => {
                                if (data) {
                                    setMovie1(data.value);
                                }
                            }}
                            required />
                        <Input
                            label="Movie 2"
                            placeholder="Enter a url for movie 2"
                            fluid
                            clearable
                            value={movie2}
                            onChange={(e, data) => {
                                if (data) {
                                    setMovie2(data.value);
                                }
                            }}
                            required />
                        <Input
                            label="Movie 3"
                            placeholder="Enter a url for movie 3"
                            fluid
                            clearable
                            value={movie3}
                            onChange={(e, data) => {
                                if (data) {
                                    setMovie3(data.value);
                                }
                            }}
                            required />
                    </div>
                </Flex.Item>
            </Flex>
        </Provider>
    );
};

First thing to note are the triple state values but also triple ref values for (in this case) 3 movie urls need to be stored in this sample. Both is needed. As the state values work best with input controls in the JSX at the bottom but they are also initially filled on load. The problem is the onSaveHandler also needs to be registered at the very beginning (on context available) and therefore any later state change won’t be reflected anymore. In React one solution for this problem is the useRef. So all that needs to be done is keep both in sync which takes place in the useEffect related to [movie1, movie2, movie3].

Additionally there are two server-side calls. One to load the initial config (in case the app was already configured) and another one to store the config values. Both is done in a server-side express router but the final Api calls of course depend on the chosen storage option while in this sample Azure App configuration is used for that.

You might also note that here a meetingID is used. This is just to define the config value key as this sample is targeting a meeting app and each meeting shall have an individual config value. A Teams Tab targeting a Teams channel might use the channelID instead.

The configuration popup will finally look like the following:

Teams Tab configuration popup

This post is dedicated to the configuration of a Teams Tab application in general based on a specific Teams Meeting app scenario. The Teams Meeting app will be explained in more detail in a later post but the configuration part should be easily adaptable to any kind of Teams Tab application. The most relevant parts were explained here or even in a previous post. While for any other reference the full solution code is available in my GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.