Configure Teams Applications with Azure App Configuration (nodeJS)

Configure Teams Applications with Azure App Configuration (nodeJS)

Most of all applications need some configuration values. Once you want to offer the option to the user to configure those values (in self service) you do not only need a UI for that but you also need to decide where to store those values. Against Microsoft SharePoint and it’s property bag for instance there is no real out-of-the-box option to store those values in Microsoft Teams applications. Okay, you might insist that if there is a Team there also is a SharePoint. But outside a native SharePoint environment (such as SharePoint Framework) access is not that easy.

So what to do in case you are using the yeoman generator for Microsoft Teams for instance? Well, probably you will end up hosting your application in an Azure Web App? So why not use Azure App Configuration for your config values?

Content

Update:

For my presentation in the Microsoft Community call I additionally implemented the usage of Microsoft Graph Search Api into this sample. And you can also watch the recording here.

Setup solution

Let’s use a simple to medium example which I already had in the past: I want to list some documents for selection and post the selected one as an Adaptive Card with an action based Teams Messaging Extension. For the retrieval of the documents I need Microsoft Graph and for constructing the endpoint in a flexible manner I need to have a “SiteID” and a “ListID”. Those values I want to receive from the user configuring the app.

First we need to setup an action based Teams Messaging Extension solution with yo teams. Similar to the following:

yo teams setup of an action based Messaging Extension

Create Azure App Configuration

Next we need to create some resources for our Messaging Extension to work. As there are:

  • A bot channel
  • A Microsoft App registration for SSO
  • Refer to both in app manifest

I described the setup in a previous series. So it won’t be repeated here.

What’s new is the need for an Azure App Configuration. First you need to create one:

Create a new Azure App Configuration

Next you need to provide some essential parameters:

Create App Configuration – Basic settings

Beside the subscription / resource group a unique resource name is important as usual for globally accessible resources. For the pricing tier you can setup one free app configuration per subscription. In a developer subscription this might be sufficient as you can have up to 10MB in storage and the app configuration can be accessed by all your apps. In a production scenario there might be security concerns when “App ABC” can access the same resource as “App XYZ” can.

So for the basic access we need at least a primary access key and based on that a connection string. That you can setup / retrieve here:

App Configuration – Get your access key / connection string

Last not least you can already insert some settings upfront:

App Configuration – Key-value setting

Code – Access App Configuration

Now the required things are setup and you are ready to give it a first try in your code. To access the app configuration endpoint you first need to install the @azure/app-configuration npm package:

npm install @azure/app-configuration

Now you can write your first lines of code. Assume you have a similar Microsoft Graph service than that one I used in my previous series. Inside you would evaluate the configured SiteID and ListID the following way:

let siteID = "";
let listID = "";
try {
  const connectionString = process.env.AZURE_CONFIG_CONNECTION_STRING!;
  const client = new AppConfigurationClient(connectionString);
  const siteSetting = await client.getConfigurationSetting({ key: "SiteID"});
  siteID = siteSetting.value!;
  const listSetting = await client.getConfigurationSetting({ key: "ListID"});
  siteID = listSetting.value!;
}
catch(error) {
  if (siteID === "") {
    siteID = process.env.SITE_ID!;
   }
   if (listID === "") {
     listID = process.env.LIST_ID!;
   }
 }
          
 const requestUrl: string = `https://graph.microsoft.com/v1.0/sites/${siteID}/lists/${listID}/items?$expand=fields`;
         

What’s important here is the try / catch block. As it might be (remember, we only added SiteID so far…) that a requested setting is not present this will cause an exception. In above’s case there is a simple fallback to local environment variables (for the moment) …

The configuration page

That’s all you need for consuming those app configuration settings. Now let’s turn to the more interesting parts: How to make them available to the user for self-service configuration. As also already described in a previous post, the first necessary setting is inside the manifest in the definition of the Messaging Extension:

"composeExtensions": [
    {
      "botId": "{{MICROSOFT_APP_ID}}",
      "canUpdateConfiguration": true,
      "commands": [
        {
          "id": "actionConfigInAzureMessageExtension",
          "title": "Action Config in Azure",
          "description": "A messaging extesion to demostrate app configuration",
          "initialRun": true,
          "type": "action",
          "context": [
            "compose",
            "commandBox"
          ],
          "fetchTask": true
        }
      ]
    }
  ],

As highlighted “canUpdateConfiguration”: true is the key here. That way you can right-click on your Messaging Extension icon to retrieve a configuration page from the bot.

Open Messaging Extensions’ Settings page

Once the “Settings” page is requested it is returned by the Messaging Extension middleware class with the onQuerySettingsUrl function.

export default class ActionConfigInAzureMessageExtension implements IMessagingExtensionMiddlewareProcessor {
  ...
  // this is used when canUpdateConfiguration is set to true
    public async onQuerySettingsUrl(context: TurnContext): Promise<{ title: string, value: string }> {
        const connectionString = process.env.AZURE_CONFIG_CONNECTION_STRING!;
        const client = new AppConfigurationClient(connectionString);
        let siteID = "";
        let listID = "";
        try {
          const siteSetting = await client.getConfigurationSetting({ key: "SiteID"});
          siteID = siteSetting.value!;
          const listSetting = await client.getConfigurationSetting({ key: "ListID"});
          listID = listSetting.value!;
        }
        catch(error) {
          if (siteID === "") {
              siteID = process.env.SITE_ID!;
          }
          if (listID === "") {
              listID = process.env.LIST_ID!;
          }
        }
        return Promise.resolve({
            title: "Action Config in Azure Configuration",
            value: `https://${process.env.HOSTNAME}/actionConfigInAzureMessageExtension/config.html?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}&siteID=${config.SiteID}&listID=${config.ListID}&useSearch=${config.UseSearch.toString()}&searchQuery=${config.SearchQuery}`
        });
    }
}

It finally returns a page url but here the page url is enhanced by the current configuration values siteID, listID and search values (to prefill fields). It’s important to know that the @azure/app-configuration package is a NodeJS package which is not used in the frontend. So we evaluate the settings here in the backend and transport them to the frontend. There the user can update them in the UI and send them back to the Bot. And finally the bot is in charge of storing the settings back to Azure. This is all what comes next. First the UI part:

Messaging Extension – Settings page
export const ActionConfigInAzureMessageExtensionConfig = () => {

    const [{ inTeams, theme }] = useTeams();
    const [siteID, setSiteID] = useState<string>();
    const [listID, setListID] = useState<string>();
    const [useSearch, setUseSearch] = useState<boolean>(false);
    const [searchQuery, setSearchQuery] = useState<string>();

    useEffect(() => {
        const initialSiteID = getQueryVariable("siteID");
        setSiteID(initialSiteID);
        const initialListID = getQueryVariable("listID");
        setListID(initialListID);
        const useSearchStr = getQueryVariable("useSearch");
        setUseSearch(useSearchStr?.toLowerCase() === "true" ? true : false);
        const initialSearchQuery = getQueryVariable("searchQuery");
        setSearchQuery(initialSearchQuery);
        if (inTeams === true) {
            microsoftTeams.appInitialization.notifySuccess();
        }
    }, [inTeams]);

    const onUseSearchChanged = (e, data) => {
        setUseSearch(data.checked);
    };

    const saveConfig = () => {
        microsoftTeams.authentication.notifySuccess(JSON.stringify({
            siteID: siteID,
            listID: listID,
            useSearch: useSearch,
            searchQuery: searchQuery
        }));
    };
    return (
        <Provider theme={theme} styles={{ height: "80vh", width: "90vw", padding: "1em" }}>
            <Flex fill={true}>
                <Flex.Item>
                    <div>
                        <Header content="Action Config in Azure configuration" />
                        <Text content="Site ID: " />
                        <Input placeholder="Enter a site id"
                            fluid
                            clearable
                            value={siteID}
                            disabled={useSearch}
                            onChange={(e, data) => {
                                if (data) {
                                    setSiteID(data.value);
                                }
                            }}
                            required />
                        <Text content="List ID: " />
                        <Input placeholder="Enter a list id"
                            fluid
                            clearable
                            value={listID}
                            disabled={useSearch}
                            onChange={(e, data) => {
                                if (data) {
                                    setListID(data.value);
                                }
                            }}
                            required />
                        <p/>
                        <Checkbox label="Use search to retrieve files instead"
                                  toggle
                                  checked={useSearch}
                                  onChange={onUseSearchChanged} />
                        <br/>
                        <Text content="Search Query: " />
                        <Input placeholder="Enter a search query such as ContentTypeId:0x0101*"
                            fluid
                            clearable
                            value={searchQuery}
                            disabled={!useSearch}
                            onChange={(e, data) => {
                                if (data) {
                                    setSearchQuery(data.value);
                                }
                            }}
                            required />
                        <p/>
                        <Button onClick={() => saveConfig()} primary>OK</Button>
                    </div>
                </Flex.Item>
            </Flex>
        </Provider>
    );
};

Two things to mention here. In the ‘Effect” hook the configuration values are retrieved from url’s search parameter. In the saveConfig function the config values are returned to the middleware as an object.

Code – Write to App Configuration

In the middleware the config values are retrieved and written back to the Azure App Configuration:

export default class ActionConfigInAzureMessageExtension implements IMessagingExtensionMiddlewareProcessor {
  ...
    public async onSettings(context: TurnContext): Promise<void> {
        // take care of the setting returned from the dialog, with the value stored in state
        const setting = JSON.parse(context.activity.value.state);
        log(`New setting: ${setting}`);
        const connectionString = process.env.AZURE_CONFIG_CONNECTION_STRING!;
        const client = new AppConfigurationClient(connectionString);
        const siteID = setting.siteID;
        const listID = setting.listID;
        if (siteID) {
          await client.setConfigurationSetting({ key: "SiteID", value: siteID });
        }
        if (listID) {
          await client.setConfigurationSetting({ key: "ListID", value: listID });
        }
        return Promise.resolve();
    }
}

Check configuration in FETCH task

Now it was not the best idea above to fallback to a pre-configured system environment value. Wouldn’t it be better to request a valid config from the user if nothing is configured, yet?
Therefore you can inject the onFetchTask function:

export default class ActionConfigInAzureMessageExtension implements IMessagingExtensionMiddlewareProcessor {
  public async onFetchTask(context: TurnContext, value: MessagingExtensionQuery): Promise<MessagingExtensionResult | TaskModuleContinueResponse> {
    const config = await Utilities.retrieveConfig();
    if(value.state && value.state?.indexOf("siteID") > -1){
      const newConfig = JSON.parse(value.state);
      await Utilities.saveConfig(config);
    }
    if (config.SiteID==="" || config.ListID ==="") { // 
      return Promise.resolve<MessagingExtensionResult>({
        type: "config", // use "config" or "auth" here
        suggestedActions: {
            actions: [
                {
                    type: "openUrl",
                    value: `https://${process.env.HOSTNAME}/actionConfigInAzureMessageExtension/config.html?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}`,
                    title: "Configuration"
                }
            ]
        }
      });
    }

    return Promise.resolve<TaskModuleContinueResponse>({
      type: "continue",
      value: {
          title: "Input form",
          url: `https://${process.env.HOSTNAME}/actionConfigInAzureMessageExtension/action.html?name={loginHint}&tenant={tid}&group={groupId}&theme={theme}`,
          height: "medium"
      }
    });
  }
  ...
}

First a try is made to retreive the config values (now extracted to a Utilities class as this is needed several times). In the overnext if the parameters are checked as in case they couldn’t be retrieved they would be kept blank “”. If so a redirect to the configuration page is returned so the user can enter values:

FetchTask Redirect – Configuration first

So if the user enters values and presses Okay this would reach back to the onFetchTask (and not the onSettings!!) function instead and therefore the first if stands for. So in this second round a simple (empty) config retrieval would be returned. Then it would be overwritten with the values from value.state, saved to the app configuration (also extracted to Utilities now!!) and finally the initial task module would be called.

Secure Access with Managed Identity

Above a secret endpoint was copied to establish a client for the app configuration. This endpoint would look like the following:

Endpoint=https://mmoteamsactionconfig.azconfig.io;Id=qrDe-l9-s0:zEIwt6M6ZH7dT+tOwen0;Secret=2B1hh5sYvSmf+jB/tooj4Q0vFzSoczbKoRyIECDGPuQ=

If you don’t like to paste secrets or credentials into app service conifgurations or environment variables you are like me. Luckily there is a much better alternative inside Azure: Managed Identities.
In short: You can establish a Managed Identity to your App Service which means provide it with an own servicePrincipal. This servicePrincipal you can then provide access to the app configuration resource and finally you do not need any secure passwords/secrets inside your code anymore. Lets do this now.

First you need to add a Managed Identity to the Azure App service:

Next this managed identity needs access to the Azure App Configuration:

Grant access to App Configuration for Managed Identity (App Service)

As there is the need to read/write the role “App Configuration Data Owner” is set. If only read access would be needed “App Configuration Data Reader” would be sufficient.

To authenticate as Managed Identity (or as an alternative for local debugging) there is the need for another npm package @azure/identity

npm install --save @azure/identity

Now there is no need for the complex secret endpoint from above anymore. So simply change the following configuration variable:

AZURE_CONFIG_CONNECTION_STRING=Endpoint=https://mmoteamsactionconfig.azconfig.io;Id=qrDe-l9-s0:zEIwt6M6ZH7dT+tOwen0;Secret=2B1hh5sYvSmf+jB/tooj4Q0vFzSoczbKoRyIECDGPuQ=

AZURE_CONFIG_CONNECTION_STRING=https://mmoteamsactionconfig.azconfig.io

That’s all. Next another a small change in establishing the client to Azure App Configuration and then it’s done.

export default class Utilities {
  public static async retrieveConfig(): Promise<Config> {
    const client = this.getClient();
    let siteID = "";
    let listID = "";
    let useSearch: boolean = false;
    let searchQuery: string = "";
    try {
      const siteSetting = await client.getConfigurationSetting({ key: "SiteID"});
      siteID = siteSetting.value!;
      const listSetting = await client.getConfigurationSetting({ key: "ListID"});
      listID = listSetting.value!;
      const useSearchSetting = await client.getConfigurationSetting({ key: "UseSearch" });
      useSearch = useSearchSetting.value?.toLowerCase() === "true" ? true : false;
      const searchQuerySetting = await client.getConfigurationSetting({ key: "SearchQuery" });
      searchQuery = searchQuerySetting.value!;
    }
    catch(error) {
      if (siteID === "") {
        //  siteID = process.env.SITE_ID!;
      }
      if (listID === "") {
        //  listID = process.env.LIST_ID!;
      }
    }
    return Promise.resolve({ SiteID: siteID, ListID: listID, UseSearch: useSearch, SearchQuery: searchQuery });
  }

  private static getClient(): AppConfigurationClient {
    const connectionString = process.env.AZURE_CONFIG_CONNECTION_STRING!;
    // const credential = new DefaultAzureCredential();
    // const client = new AppConfigurationClient(connectionString, credential);
    let client: AppConfigurationClient;
    if (process.env.AZURE_CLIENT_SECRET) {
      const credential = new EnvironmentCredential();
      client = new AppConfigurationClient(connectionString, credential);
    }
    else {
      const credential = new ManagedIdentityCredential();
      client = new AppConfigurationClient(connectionString, credential);
    }
    return client;
  }
}

The easiest way is to use DefaultAzureCredential which will try to find one out of several ways to establish a valid credential option. I commented this out and manually chained two possible options for yo teams:
In case there is no local .env variable AZURE_CLIENT_SECRET a simple ManagedIdentityCredential is chosen and with that and aboves https endpoint the client is established. The alternative for local debugging is the EnvironmentCredential which will be established based on three .env variables. Therefore you need to register an app, give it a secret and grant access to your App Configuration as you did above already with the Managed Identity. Then you fill the following variables in your local .env and have the alternative for local debugging:

AZURE_TENANT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=

That is all you need to establish an enterprise ready configuration mechanism for your Microsoft Teams application. I pointed out here the essential points but for your reference feel free to inspect the whole solution available in my github repository.

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

3 thoughts on “Configure Teams Applications with Azure App Configuration (nodeJS)

Leave a Reply

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

WordPress.com Logo

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

Google photo

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

Twitter picture

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

Facebook photo

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

Connecting to %s