Tag: Blazor

An action-based Teams and M365 Messaging Extension

An action-based Teams and M365 Messaging Extension

This post shows how to implement an action-based Teams and M365 Messaging Extension with Teams Toolkit for Visual Studio 2022 and using C#, Blazor incl FluentUI. Such Messaging Extensions can be used “across” Microsoft 365 in Teams but also Outlook or as Cópilot plugin. It will make use of rich UI capabilities implemented in so called task modules. But let’s see what this means in detail. First a quick look at the functionality to be implemented:

App in action
App in action

Once the action is called a task module is shown where the user can select the product and on selection, this product is returned to the users compose box, where it can be posted as an adaptive card. If orderable the product can be ordered by clicking the corresponding action and it will be returned with a read only card and the new orders value.

Content

Setup

For setting up a solution first in Visual Studio 2022 a Teams Messaging Extension needs to be set up.

Set up Teams Messaging Extension
Set up Teams Messaging Extension

Unfortunately, this isn’t all as this project template doesn’t contain any kind of UI. But this will be established in the next section. So far some more adjustments to the (teams) manifest must be made.

"bots": [
{
 "botId": "${{BOT_ID}}",
 "scopes": [ "personal", "team", "groupchat" ],
 "isNotificationOnly": false,
 "supportsFiles": false
 }
],
"commands": [
{
"id": "selectItem",
"context": [
"compose",
"message",
"commandBox"
],
"description": "Command to select item from a list",
"title": "Select Item",
"type": "action",
"fetchTask": true
}
},

“validDomains": [
"${{BOT_DOMAIN}}",
“token.botframework.com”
]

Enable UI (Blazor)

In a former post, I did already describe how to enable Blazor UI in a Teams messaging extension solution where it’s not available out of the box. This is still the case so here it will be described once again not only for the sake of completeness, but also for a more detailed and fixed version.

Although recommended with .Net8 to switch from a Blazor Web Assembly to a Blazor Web App I struggled a bit to be honest and sticked to the LTSC version of .Net6. As in the former description a server side application was chosen here once again it will be picked.

Against the assumption in the last post some more basic files for Blazor component routing are relevant/needed:

Files relevant/needed for Blazor usage
Files relevant/needed for Blazor usage

In the Program.cs the following lines need to be added, so staticFiles and Blazor components can be requested / routed

builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor(o => o.DetailedErrors = true);
builder.Services.AddHttpClient(…);

An App.razor is establishing basic routing while _imports.razor is responsible for imports valid for all razor components or pages.

As basic page “holder” of all developed Blazor components acts by default the _Host.cshtml. Here also the basic layout page (_Layout) is pulled in.

Foremost, here in the _Layout.cshtml some general scripts like FluentUI, TeamsJS SDK or blazor.server.js are loaded.

For the TeamsJS SDK against what the Teams Toolkit still normally offers I decided to go here with the latest stable one:

https://res.cdn.office.net/teams-js/2.19.0/js/MicrosoftTeams.min.js

This is especially because of the needed but still a bit a behind of the production ready microsoftTeams.dialog.url.submit() JS function.

Also referring to a css file named like the ProjectAssembly is necessary.

<link href="MsgextActionSrchData.styles.css" rel="stylesheet" />

This will later include all css directly added (by filenames + css) css files to their razor components.

FluentUI

After Blazor now it’s also time and possible to add the latest version available for.Net6 (or 7) of Blazor-FluentUI components which is 3.5.2.

At first two packages need to be added:

dotnet add package Microsoft.Fast.Components.FluentUI
dotnet add package Microsoft.Fast.Components.FluentUI.Icons

Next the following script needs to be loaded in the _Layout.cshtml.

https://unpkg.com/@@fluentui/web-components

Finally, in Program.cs the following line needs to be added:

builder.Services.AddFluentUIComponents();

Now the first UI component can be built.

Initial Task Module

The initial task module is called from fetchTask=true inside the teams manifest (see above under setup) and then forwarded to the bot backend code. Here it is handled and a Task is opened which consists a URL that represents a page displayed in an iframe.

string taskModuleUrl = $"{_config["BotEndpoint"]}initialaction";
return new MessagingExtensionActionResponse
{
  Task = new TaskModuleContinueResponse
  {
    Type = "continue",
    Value = new TaskModuleTaskInfo
    {
      Width = 720,
      Height = 360,
      Title = "Select a Product",
      Url = taskModuleUrl
    }
  }
};

The page is rendered by Blazor as set up above.

Task Module to select a product
Task Module to select a product

The core functionality inside the page representing the task module will be explained in the following sections.

JSInterop

On the EventHandler of the FluentButton handled once a product is selected and the button is clicked there is the problem. It expects a C# function but to proceed finally a JS call is needed. Teams JS SDK 2.0 is responsible to submit the task with only a single line of code:

microsoftTeams.dialog.url.submit()

But this enforces a couple of problems. Although Teams Toolkit already supports some JSInterop functionality the whole tasks module is not available (additionally there is a new dialog module instead but not yet fully supported). As it’s only needed once in this single component the shortest way is to put it into 2 pieces inside the component. The C# part is the FluentButton calling a C# function.

<FluentButton Appearance="Appearance.Accent" @onclick="SubmitTeamsTask">Submit Task</FluentButton>
...
@code
{
    private async Task SubmitTeamsTask()
    {
        await JS.InvokeVoidAsync("submitTasks");
    }

And inside the SubmitTeamsTask the call to the plain JS function is done (assuming _Layout.cshtml or _Host.cshtml already loaded and initialized Teams JS SDK 2.0).

<script type="text/javascript">
    function submitTasks() {
        var hiddenLabel = document.getElementById('prodName');
        var selectedId = hiddenLabel.getAttribute('data-prodid');
        var selectedName = hiddenLabel.getAttribute('data-name');
        var selectedOrders = hiddenLabel.getAttribute('data-orders');
        var selectedOrderable = false;
        if (hiddenLabel.dataset.orderable !== undefined) {
            selectedOrderable = true;
        }
        var result = { Id: selectedId, Name: selectedName, Orders: selectedOrders, Orderable: selectedOrderable };
        microsoftTeams.dialog.url.submit();
    }
</script>

The “trick” here is a bit ‘old school but it’s simply a hidden label holding all the parameters from the selected product putting them in an object an sending it to the server.

<label id="prodName"
 class="hiddenLabel"
  aria-hidden="true"
 data-name="@SelectedItem?.Name"
 data-prodid="@SelectedItem?.Id"
 data-orders="@SelectedItem?.Orders"
 data-orderable="@SelectedItem?.Orderable">

Additionally it might be worth to add if no clear Task or better now Dialog is opened with the OnTeamsMessagingExtensionFetchTaskAsync method there might occur issues with submitting it by microsoftTeams.dialog.url.submit(result);

Data selection

On the data side simply an Azure Table was chosen. This can be changed to any kind of data source needed and accessible of course. So the kind of data is not the focus here in this sample. It’s the dealing, the flow and processing with it as well as the options to display and access it from Microsoft 365.

The first thing was to present a rich UI (richer and more flexible as a simple adaptive card could present) on first request rendering the available data for further filtering or selection. Beside the simpler possibility in a search-based messaging extension here a so called task module, that is nothing else than a webpage rendered in an iframe giving a rich UI using Blazor and FluentUI shall be used (see above).

For data selection a simple WebApi Controller is established. With dependency injection it is made available to the Blazor components where three methods (for all, orderable or non-orderable) can be called.

public List<Product> GetAllProducts()
{
  List<Product> products = new List<Product>();
  Pageable<TableEntity> list = tableClient.Query<TableEntity>();
  foreach (TableEntity item in list)
  {
    Product p = new Product()
    {
      Id = item.PartitionKey,
      Name = item.RowKey,
      Orders = (int)item.GetInt32("Orders"),
      Orderable = (bool)item.GetBoolean("Orderable")
    };
    products.Add(p);
  }
  return products;
}
public List<Product> GetOrderableProducts()
{
  ...
}
public List<Product> GetNonOrderableProducts()
{
  ...
}

Once an item, that is a product in fact, is selected it can be sent back to the server on button click. This will then reach the bot’s backend controller. Here it will be transformed to an adaptive card and sent back to the user’s compose box.

Result (Adaptive Card)

Turned into an adaptive card, the four values are taken and transformed: ID, Name and number of Orders are displayed and if the product is Orderable also an action is shown to order another amount of the corresponding product.

Adaptive Card Product Order Result
Adaptive Card Product Order Result

Performing an “Order” action will indeed increase the number of orders which can be checked by calling the original command once again. Performing the always visible “View” option will return another adaptive card with only view mode, no matter if orderable or not.

var actionData = ((JObject)invokeValue.Action.Data).ToObject<ProductUpdate>();
...
// Update Orders
ProductController productCtrl = new ProductController(_config);
Product resultProduct = productCtrl.UpdateProductOrders(actionData);

Inside the bot framework method the product incl update value is received. This can be transferred to the ProductController to update the orders value correctly.

public Product UpdateProductOrders(ProductUpdate product)
{
  TableEntity pEntity = tableClient.GetEntity<TableEntity>(product.Id, product.Name);
  int newOrders = product.Orders + product.orderId;
  pEntity["Orders"] = newOrders;
  tableClient.UpdateEntityAsync(pEntity, pEntity.ETag);
  TableEntity updatedEntity = tableClient.GetEntity<TableEntity>(product.Id, product.Name);
  return new Product()
  {
    Id = updatedEntity.PartitionKey,
    Name = updatedEntity.RowKey,
    Orders = (int)updatedEntity.GetInt32("Orders"),
    Orderable = (bool)updatedEntity.GetBoolean("Orderable")
  };
}

Outlook (M365) considerations

Although this solution approach is considered not only for Teams but as a Microsoft 365 across solution at the time of writing for Outlook action based and task modules are still in preview.

The solution needs at least manifest version 1.13+ but with currently created solutions this should be no problem.

For the bot it is necessary to add the “Microsoft 365 channel” which might confuse a bit at a first glance because there is an Outlook channel, too. But in fact this is no longer used. So adding the Microsoft 365 channel is the only but mandatory prerequisite:

add the "Microsoft 365 channel" to an Azure's bot channels
Add the “Microsoft 365 channel”

Otherwise the invoke request will get an “BotNotConfiguredForChannel” error.

But if configured correctly this results now in the capability to open the extension via “App” in a new created Email and insert an adaptive card in the Email’s compose box same way as already described for Teams.

Order Card result in Oùtlook with weekday order option
Order Card result in Oùtlook with weekday order option

In my first community call presentation about Microsoft 365 across apps I criticized or missed that messaging extensions do not offer action-based variants including task modules beyond Teams. Now they are available as seen here. An interesting scenario will be how to use them as copilot plugins. This sample is going to be tried out soon in that direction and likely extended. Meanwhile, for your reference the whole sample is already 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.
Creating Teams Meetings and install Teams Meeting App with Microsoft Graph

Creating Teams Meetings and install Teams Meeting App with Microsoft Graph

Creating Teams meetings with Microsoft Graph it not a sophisticated task. Microsoft already showed this in a scenario where Microsoft Teams and Azure Communication services came together. But let’s extend this scenario coming from a context where already some custom data is available which shall be reused in a custom Teams meeting app that shall be installed in the just created Teams meeting.

To make it easy, the context that shall execute all the things will be simulated by a simple Console app. In reality, this can be a web service, a bot or whatever. In a high-level architecture it can look like this:

High-level process: Create meeting, install app, create and display custom data
High-level process

Beside the custom data, which will be a fake customer in this scenario some data for the meeting is needed. In fact, these are two attendees a subject and a start and end date. One user of course can be the user executing the request. But only in the scenario of a delegated user context which leads us to the question if an app or user permission scenario is used for authentication.

Content

Authentication

Here are both options, controlled by a bool. For security reasons prefer the delegated option but if your context doesn’t offer a user it’s only possible to follow the app context way.

internal class GraphController
{
    private bool userScope = false; // true=Delegated, false=app
    private string[] scopes = new[] { "https://graph.microsoft.com/.default" };
    private GraphServiceClient graphClient;        
    public GraphController(string tenantId, string clientId, string clientSecret) 
    {
        string accessToken;
        if (userScope)
        {
            var clientApplication = PublicClientApplicationBuilder.Create(clientId)
                                                 .WithRedirectUri("http://localhost")
                                                 .WithAuthority(AzureCloudInstance.AzurePublic, tenantId)
                                                 .Build();
            accessToken = clientApplication.AcquireTokenInteractive(scopes).ExecuteAsync().Result.AccessToken;
            HttpClient _httpClient = new HttpClient();
            _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            graphClient = new GraphServiceClient(_httpClient);
        }
        else
        {
            var options = new ClientSecretCredentialOptions
            {
                AuthorityHost = AzureAuthorityHosts.AzurePublicCloud,
            };
            var clientSecretCredential = new ClientSecretCredential(
                    tenantId, clientId, clientSecret, options);
            var tokenRequestContext = new TokenRequestContext(scopes);
            graphClient = new GraphServiceClient(clientSecretCredential, scopes);
        }
    }

Of course the authentication is a bit tailored to a simple console app and might look slightly different in a different context, especially the delegated one when having to deal with SSO. But this is not the main topic here. That starts next with creating the meeting.

Meeting creation

This works the same in app and delegated context btw:

public async Task<string> CreateTeamsMeeting(string userId, string userPrincipalName, string dummyAttendee, string meetingSubject)
{
    var attendeeList = new List<Attendee>();
    Attendee attendee = new Attendee { EmailAddress = new EmailAddress { Address = dummyAttendee } };
    attendeeList.Add(attendee);
    Event evt = new Event
    {
        Subject = meetingSubject,
        IsOnlineMeeting = true,
        Organizer = new Recipient
        {
                    EmailAddress = new EmailAddress { Address = userPrincipalName }
        },
        Attendees = attendeeList,
                Start = new DateTimeTimeZone
                {
                    TimeZone = "Europe/Berlin",
                    DateTime = DateTime.Now.ToString("s")
                },
                End = new DateTimeTimeZone
                {
                    TimeZone = "Europe/Berlin",
                    DateTime = DateTime.Now.AddHours(1).ToString("s")
                }
            };
            var newEvent = await graphClient.Users[userId].Calendar.Events.PostAsync(evt);
            return newEvent.OnlineMeeting.JoinUrl;
        }

The meeting is created as an onlineMeeting. Once it is created, it is important to get the chatID of the onlineMeeting which can only be done by a selected call filtering for the JoinUrl.

public async Task<string> GetMeetingChatId(string userID, string joinUrl)
{
    var onlineMeeting = await graphClient.Users[userID].OnlineMeetings
          .GetAsync((requestConfiguration) =>
          {
              requestConfiguration.QueryParameters.Filter = $"joinWebUrl eq '{joinUrl}'";
          });
    string chatId = onlineMeeting.Value[0].ChatInfo.ThreadId;    
    return chatId;
}

App installation

The chatID is necessary because inside the chat the custom meeting app is to be installed as a tab. The equality of chatID and meetingID and how it’s accessible client-side in a meeting app is best described in Yannick Reekmans blog post.

To install an app additionally the tenant specific appID inside the tenant app catalog is needed. This can be evaluated by a filter call for an organization app with the known name.

public async Task<string> GetAppId()
{
    var apps = await graphClient.AppCatalogs.TeamsApps.GetAsync((requestConfiguration) =>
    {
          requestConfiguration.QueryParameters.Filter = $"distributionMethod eq 'organization' and displayName eq 'TeamsMeetingServiceCall'";
    });
    string appId = "";
    if (apps.Value != null)
    {
          appId = apps.Value.First<TeamsApp>().Id ?? "";
    }
    return appId;
}

Having both IDs the app can be installed into the chat of the meeting as a tab.

public async Task<bool> InstallAppInChat(string appId, string chatId)
{
    var requestBody = new TeamsAppInstallation
    {
        AdditionalData = new Dictionary<string, object>
        {
            {
                "teamsApp@odata.bind" , $"https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/{appId}"
            },
        }
    };
    var result = await graphClient.Chats[chatId].InstalledApps.PostAsync(requestBody);
    return true;
}

Running the console app might give a response similar to this one:

Console app creating a Teams meeting and installing a custom meeting app
Console app creating a Teams meeting and installing a custom meeting app

But it does even more for the whole sample scenario:

Custom data

On top of the meeting creation and app installation some custom data shall be made visible in the meeting. This can be easily done with the installed app. The only challenge is the data storage. Originally the preferred way should be an OpenExtension via Microsoft Graph with the meeting itself. Unfortunately, this is not (yet?) possible. (You would need to get back from the chat via onlineMeeting? to the event (only this supports openextensions) of the organizer but cannot filter by onlineMeeting/joinUrl backwards; try yourself with Graph Explorer) So another option needs to be found. In the past I already worked with Azure App Configuration. Additionally another option, an Azure Table is shown here.

Azure App configuration

Azure App Configuration is good for storing key value pairs. The meetingID is a good portion for the key, combined with the app name and the attribute name. So write some customer data for instance would simply look like this:

internal class AzureController
{
    private readonly ConfigurationClient _client;
    public AzureController(IConfiguration config)
    {
        string _connectionString = config["AZURE_CONFIG_CONNECTION_STRING"];
        if (_connectionString.StartsWith("Endpoint"))
        {
            _client = new ConfigurationClient(_connectionString);
        }
        else
        {
            _client = new ConfigurationClient(new Uri(_connectionString), new ClientSecretCredential(config["AZURE_TENANT_ID"], config["AZURE_CLIENT_ID"], config["AZURE_CLIENT_SECRET"])); // Better: ManagedIdentityCredential
        }
    }
    public void storeConfigValue(string key, string value)
    {
        _client.SetConfigurationSetting(key, value);
    }

While the storing of the configuration value is pretty straightforward, the client initialization depends on the connection string. If it starts with “Endpoint” a simple secret string can be expected otherwise a credential client or a managed identity is used.

If you already have a suiting App Configuration resource that can be used, this is a cheap and suitable solution. Otherwise, Azure Table might be the cheaper and better option:

Azure Table

Writing the same data to an Azure Table row would first require a partition and row key. Essential once again would be the meetingID as this is is the only data available in the meeting (app) from the very beginning. So writing the same data as above would look like this:

internal class AzureTableController
{
    private TableClient tableClient;
    public AzureTableController(IConfiguration config)
    {
        string accountName = config["AZURE_TABLE_ACCOUNTNAME"];
        string storageAccountKey = config["AZURE_TABLE_KEY"];
        string storageUrl = $"https://{accountName}.table.core.windows.net/";        
        tableClient = new TableClient(new Uri(storageUrl), "Customer", new TableSharedKeyCredential(accountName, storageAccountKey));
    }
    public void CreateCustomer(string meetingID, Customer customer)
    {
      var tableEntity = new TableEntity(meetingID, customer.Id)
      {
        { "Name", customer.Name },
        { "Email", customer.Email },
        { "Phone", customer.Phone }
      };
      tableClient.AddEntity(tableEntity);
    }

Similar to the Azure app configuration at first a client needs to be established. Therefore, more than one configuration value is needed. For the storing a meetingID and the customer object is taken. The meetingID is taken for the partition key and the id from the customer is taken for the row key. The rest of the data is written to the entity itself.

Teams meeting app (display custom data)

Once a meeting is created, the custom app is installed and the custom data is written to a storage of your choice the first part is done. Now in the second part the custom app needs to ensure (before?) or during a running meeting the custom data can be made visible again. Therefore, a simple tab needs to access the storage, pick the right values and display the custom data.

This post will omit to explain the very basics of a teams meeting tap application. This in detail is explained here. So the concentration is put on retrieving the custom values, may it come from Azure app configuration or an Azure table.

Azure App configuration

Once again at first a client needs to be established before the values can be read;

internal class AzureController
{
    private readonly ConfigurationClient _client;
    public AzureController(string _connectionString)
    {
        if (_connectionString.StartsWith("Endpoint"))
        {
               _client = new ConfigurationClient(_connectionString);
        }
        else {...}
    }
    public string GetConfigurationValue(string key)
    {
        var configValue = _client.GetConfigurationSetting(key);
        if (configValue != null)
        {
            return configValue.Value.Value;
        }
        else { return ""; }
    }

Then the Value can be read (yes, Value.Value is correct her) by the given key which is constructed as mentioned above.

Azure Table

Also here at first a client is needed. The get part requires some more lines because by a query for the partition key more than one results can be expected. Here simply the first one is taken and returned as a custom object.

internal class AzureTableController
{
    private TableClient tableClient;
    public AzureTableController(IConfiguration config) {
        string accountName = config["AZURE_TABLE_ACCOUNTNAME"];
        string storageAccountKey = config["AZURE_TABLE_KEY"];
        string storageUrl = $"https://{accountName}.table.core.windows.net/";
        tableClient = new TableClient(new Uri(storageUrl), "Customer", new TableSharedKeyCredential(accountName, storageAccountKey));
    }
    public CustomerData GetCustomer(string meetingID)
    {
      Pageable<TableEntity> queryResults = tableClient.Query<TableEntity>(filter: $"PartitionKey eq '{meetingID}'");
      var custEntity = queryResults.First<TableEntity>();
        CustomerData customer = new CustomerData()
       {
          Id = custEntity.RowKey,
          Name = custEntity.GetString("Name"),
          Email = custEntity.GetString("Email"),
          Phone = custEntity.GetString("Phone")
      };
      return customer;
}

In reality the result can look like this in a in-meeting experience inside the side-panel:

In-meeting experience showing custom data in the side-panel
In-meeting experience showing custom data in the side-panel

This post has shown a story nearly from real life where a meeting automatically got created with a custom app installed which displays some custom data retrieved from an outside context. Of course it was a bit simplified for a better understanding. The whole simple code can be evaluated in my GitHub repository as always.

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 Teams Toolkit for Visual Studio (C# & Blazor) to create a Teams Tab using SSO for SharePoint CSOM (PnP)

Use Teams Toolkit for Visual Studio (C# & Blazor) to create a Teams Tab using SSO for SharePoint CSOM (PnP)

In the recent past I started to create/rebuild my existing Teams app samples towards C# using the Teams Toolkit for Visual Studio. I enabled usage of Microsoft Graph Toolkit, established SSO to access Microsoft Graph in a WebApi backend application or configured a Message Extension with Bot SSO. I also started to get rid of JavaScript by replacing with Blazor. In this post and with the latest version of Teams Toolkit for Visual Studio (17.7) I want to rebuild my last sample using completely Blazor and C# even for the frontend stuff. Additionally instead of Microsoft Graph I’m going to use SharePoint CSOM API powered by PnP.Core SDK.

The sample still is a “Microsoft 365 across” application, a personal Teams Tab also to be used in Outlook, M365 (and SharePoint) creating an offer document in SharePoint based on a custom Word template and enriched by custom metadata. The initial sample based on NodeJS and yo teams can be found here for comparison.

Content

The form

Beyond the prerequisites of a custom content type the first thing needed is an input form for the metadata (it’s not different from the Microsoft Graph version):

Teams Tab – Input form for Offer Metadata
Teams Tab – Input form for Offer Metadata

In Blazor this is established like this using some FluentUI components:

<EditForm Model="@exampleModel" OnSubmit="@load" >
  <div class="form">
      <div>
          <FluentTextField @bind-Value="@exampleModel.Title">Title</FluentTextField>
      </div>
      <div>
          <label class="formLabel">Offer Date</label>
      </div>
      <div>          
          <InputDate @bind-Value=@exampleModel.OfferDate />
      </div>
      <div class="formLine">
          <FluentNumberField @bind-Value="@exampleModel.Price" Step=".01">Price</FluentNumberField>
      </div>
      <div class="formLine">
          <label class="formLabel">Offer Date</label>
          <label>VAT</label>
      </div>
      <div>          
          <FluentSelect Items=@vatOptions OptionText="@(i => i.Text)" OptionValue="@(i => i.Value)" @bind-Value="@exampleModel.SelectedVAT">VAT</FluentSelect>
      </div>
      <div class="formLine">
          <FluentTextArea Resize="TextAreaResize.Vertical" @bind-Value="@exampleModel.Description">Description</FluentTextArea>
      </div>
      <div class="formLine">
        <FluentButton Appearance="Appearance.Accent" Type="ButtonType.Submit">Save</FluentButton>
      </div>      
    </div>
 </EditForm>
....
public Offer exampleModel = new()
{
    OfferDate = DateTime.Today,
    SelectedVAT = ".19"
};

The first thing is an EditForm bound to a Class representing the Offer object. All the field controls are then bound to a specific class attribute such as Title. The object itself is instantiated initially with some default values in the @code {} section. The “Save” button only executes a simple Submit. What exactly happens on this event is defined in the EditForm as well: OnSubmit="@load". The load function can be found in the @code {} section as well.

But except a loading indicator established by a FluentProgressRing and a simple result display this function is mainly dedicated to establish a backend connection. Therefore, it is covered in the next two sections.

SSO

To establish a backend connection at first an authentication token is needed. Having that a HttpClient can call the backend WebApi, while the function finally can handle the response.

 async void load()
{
        isLoading = true;
        resultAvailabe = false;
        var result = await teamsUserCredential.GetTokenAsync(new TokenRequestContext(new string[] { }), new System.Threading.CancellationToken());
        string token = result.Token;
        var request = new HttpRequestMessage(HttpMethod.Post, "/api/Offer");
        request.Headers.Add("Accept", "application/json");
        request.Headers.Add("User-Agent", "HttpClientFactory-Sample");
        request.Content = new StringContent(JsonSerializer.Serialize(exampleModel), Encoding.UTF8, "application/json");
        var client = ClientFactory.CreateClient();
        client.BaseAddress = new Uri(MyNavigationManager.BaseUri);
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
        var response = await client.SendAsync(request);
        if (response.IsSuccessStatusCode)
        {
            resultMessage = await response.Content.ReadAsStringAsync();
            resultAvailabe = true;
        }
        isLoading = false;
        StateHasChanged();
}

First with the teamsUserCredential a token can be requested. As currently only an id or bootstrap token is needed, an empty scope new string[] { } can be provided. This will result in an access_as_user permission only inside the token (check with jwt.ms for instance).

Only in the backend things become a little bit different because getting an access token by using SharePoint API (no matter if Rest or CSOM) needs a different scope. But this mainly is handled by PnP authentication.

For the final token the backend WebApi is responsible. The call itself is no rocket science anymore. With an AuthenticationHeaderValue("Bearer", token) the request.Content containing the custom form object exampleModel can be POSTed. In case of a successful request the response will be the webUrl of the created document which can be presented as a link in the UI. But for the details see next section.

Backend WebApi

For comfortable SharePoint CSOM API access the following two packages still need to be added:

<PackageReference Include="PnP.Core" Version="1.10.0" />
<PackageReference Include="PnP.Core.Auth" Version="1.10.0" />

Having that in Program.cs some services can be added via dependency injection:

// Add the PnP Core SDK library
builder.Services.AddPnPCore();
builder.Services.Configure<PnPCoreOptions>(builder.Configuration.GetSection("PnPCore"));
builder.Services.AddPnPCoreAuthentication();
builder.Services.Configure<PnPCoreAuthenticationOptions>(builder.Configuration.GetSection("PnPCore"));

I clearly prefer that way against what is simply offered to you in the current Teams Toolkit template: Using Microsoft Graph SDK directly inside the Blazor components. In my personal opinion it is much clearer, especially towards more complex enterprise scenarios to have a dedicated WebApi controller. BTW no matter if using Microsoft Graph or SharePoint CSOM / Rest API, whatever… And in case of a C# Teams app which you need to host in Azure or similar anyway (while in SPFx for instance the much leaner hosting might speak against a dedicated backend component).

So if a request arrives at the backend an OfferController is established and by dependency injection the constructor can expect the following:

[Route("api/[controller]")]
[ApiController]
public class OfferController : ControllerBase
{
        private readonly GraphServiceClient _graphClient;
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly IPnPContextFactory _pnpContextFactory;
        private readonly ILogger<GraphController> _logger;
        private readonly PnPCoreOptions _pnpCoreOptions;
        public OfferController(IPnPContextFactory pnpContextFactory, ITokenAcquisition tokenAcquisition, GraphServiceClient graphClient, ILogger<GraphController> logger,
            IOptions<PnPCoreOptions> pnpCoreOptions)
        {
            _tokenAcquisition = tokenAcquisition;
            _graphClient = graphClient;
            _pnpContextFactory = pnpContextFactory;
            _logger = logger;
            _pnpCoreOptions = pnpCoreOptions?.Value;

Having that, the main POST function can look like this while SharePoint specific (or Microsoft Graph) operations take place in an extracted own Controller function.

[HttpPost]
public async Task<ActionResult<string>> Post(Offer offer)
{
        string userID = User.GetObjectId(); //   Claims["preferred_username"];
        _logger.LogInformation($"Received from user {userID} with name {User.GetDisplayName()}");
        _logger.LogInformation($"Received Offer {offer.Title} with descr {offer.Description}");
        SPOController spoCtrl = new SPOController(_tokenAcquisition, _pnpContextFactory, _logger, _pnpCoreOptions);
        string result = await spoCtrl.CreateOfferFromTemplate(offer);
        return result;
}

Having a SPOController established by dependency injection the constructor can expect the following:

public class SPOController : ControllerBase
{
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly IPnPContextFactory _pnpContextFactory;
        private readonly ILogger<GraphController> _logger;
        private readonly PnPCoreOptions _pnpCoreOptions;
        public SPOController(ITokenAcquisition tokenAcquisition, IPnPContextFactory pnpContextFactory, ILogger<GraphController> logger, PnPCoreOptions pnpCoreOptions)
        {
            _tokenAcquisition = tokenAcquisition;
            _pnpContextFactory = pnpContextFactory;
            _logger = logger;
            _pnpCoreOptions = pnpCoreOptions;
        }

Having retrieved the custom offer object at first a dedicated template (here hardcoded: „Offering.dotx“ but from the official custom content type template) needs to be downloaded into memory with the following steps.

public async Task<string> CreateOfferFromTemplate(Offer offer)
{
    using (var context = await createSiteContext())
    {
        var file = await context.Web.GetFileByServerRelativeUrlAsync($"{context.Uri.PathAndQuery}/_cts/Offering/Offering.dotx");               
        Stream downloadedContentStream = await file.GetContentAsync();
        ....
    }
    private async Task<PnPContext> createSiteContext()
    {
        var siteUrl = new Uri(_pnpCoreOptions.Sites["DemoSite"].SiteUrl);
        return await _pnpContextFactory.CreateAsync(siteUrl,
               new ExternalAuthenticationProvider((resourceUri, scopes) =>
               {
                   var token = _tokenAcquisition.GetAccessTokenForUserAsync(scopes).GetAwaiter().GetResult();                   
                   return token;
               }
          ));
}

As one specific operation next a “copy” of the template needs to be executed. In detail it is a two-liner and looks like this:

IFolder docs = await context.Web.Folders.Where(f => f.Name == "Shared Documents").FirstOrDefaultAsync();
IFile newFile = await docs.Files.AddAsync($"{offer.Title}.docx", downloadedContentStream, false);

As parameters the source Stream (downloadedContentStream), the target location (assuming root of the standard documents library) and the target filename (offer.Title + .docx) are necessary. Having that, the File can simply be added by a single call. The result can directly be used for the final operation to update the corresponding listItem‘s metadata:

newFile.ListItemAllFields["Title"] = offer.Title;
newFile.ListItemAllFields["OfferingDescription"] = offer.Description;
newFile.ListItemAllFields["OfferingNetPrice"] = offer.Price.ToString();
newFile.ListItemAllFields["OfferingDate"] = offer.OfferDate;
newFile.ListItemAllFields["OfferingVAT"] = offer.SelectedVAT.Replace(".", ""); // "19" instead of ".19"
await newFile.ListItemAllFields.UpdateAsync();
return newFile.ServerRelativeUrl;

When comparing this to my Microsoft Graph alternative it looks much leaner and the field values need to be formatted partially different. I really hope this gets aligned in the near future.  But also refer to my dedicated blog post on writing list items and column data.

Response

As omitted in detail but mentioned above there is also a response handling in the UI. First there is a Spinner or FluentProgressRing to indicate that the backend operation is running. It is controlled by a bool isLoading which is set to true at the beginning and re-set to false at the end of the backend operation. This looks like this in action:

Teams app in action – Filling metadata and creating document
Teams app in action – Filling metadata and creating document

In code it looks like this:

@if (isLoading)
{
    <div style="display: flex; justify-content: center; align-items: center;">
        <FluentProgressRing />
    </div>
}
@if (resultAvailabe)
{
    <label>Document was created and can be found <a href=@resultMessage> here</a></label>
}
@code {
....
    async void load()
    {
        isLoading = true;
        resultAvailable = false;
        ...
        if (response.IsSuccessStatusCode) {
        {
            resultMessage = await response.Content.ReadAsStringAsync();
            resultAvailabe = true;
        }
        isLoading = false;
        StateHasChanged();

In terms of a successful operation another bool resultAvailable is set to true. Also the result, that is the webUrl of the created document, is written to a variable. Both is used to display the result as a label with a link. But to update the page finally StateHasChanged(); has to be called and that’s it.

Here now I established to access the SharePoint CSOM (simplfied with PnP.Core) API inside a Teams tab on behalf of SSO, which is still more comfortable for this kind of solution. The search-based message extension from the final version of the “original” NodeJS sample is missing. Once it becomes easy with Teams Toolkit for Visual Studio to add additional Teams application types to an existing solution I’ll cover and add that as well. Meanwhile the current version of the full solution is available in my GitHub repository.

Also treat this sample as a ‘pattern’ how you can establish your own CSOM or PnP.Core based Teams application. May it be a personal tab, with or without M365 across scenario or a configurable Tab. 

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 Applications with Azure App Configuration (C#)

Configure Teams Applications with Azure App Configuration (C#)

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.

In the past I already created a demo using the yeoman generator for Microsoft Teams. Now with Teams Toolkit for Visual Studio you will end up hosting your application in an Azure Web App, again. Here you can use Azure App Configuration for your config values, too.

Content

Setup solution

First, a new Visual Studio Teams Solution needs to be created. Therefore, Visual studio 2022 in the version 17.3 or above should be installed. This enables to install Teams Toolkit in GA version.
A new Teams Application with the Application Type “Message Extension” shall be created:

Create new Teams Application - Messaging Extension with Visual Studio
Create new Teams Application – Message Extension

Inside the created solution the manifest.template.json directly should be adjusted. From the composeExtensions remove every command except the type: "query" one. Also add "canUpdateConfiguration": true so the Messaging Extension becomes configurable.

"composeExtensions": [
{
          "botId": "{{state.fx-resource-bot.botId}}",
          "canUpdateConfiguration": true,
          "commands": [
              {
                  "id": "searchDoc",
                  "title": "Search Document",
                  "description": "Searches for documents",
                  "initialRun": true,
                  "parameters": [
                   {
                     "name": "parameter",
                     "description": "Search for documents",
                     "title": "Parameter"
                   }
                 ],
                 "type": "query"
    

Furthermore several NuGet packages need to be installed. Two for handling Azure App Configuration and two for proper UI handling:

  • Microsoft.Extensions.Configuration.AzureAppConfiguration
  • Microsoft.Azure.AppConfiguration.AspNetCore
  • Microsoft.Fast.Components.FluentUI
  • AdaptiveCards

Create Azure App Configuration

Creating an Azure App Configuration is quite simple:

Create Azure App Configuration

Beside the typical Azure Attributes, such as Subscription, Resource Group or Location only a name and a Pricing Tier is needed. Especially for Development Environments interesting: There is one free resource available, where you can put your stuff in. But then it’s essential to have proper naming conventions for your keys. For productional or even enterprise scenarios I clealy do not recommend this as App A for sure does NOT need access to config of App B.

As proper naming convention was already mentioned, on the Configuration Explorer the Key/Value entries can be entered.

Azure App Configuration - Key/Value entries
Azure App Configuration – Key/Value entries

One entry needs specific notice. that is the Sentinel:

Create Azure App Configuration - Key/Value entry
Create Azure App Configuration – Key/Value entry

This value will be used later (see below) to act as a “trigger” for dynamic refresh. Only when this value is changed (simply incremented) the consuming app will refresh its configuration.

Last not least a connectionString is needed. This can be copied from the Primary Access Key Connection string:

Azure App Configuration - Access Keys - Primary Connection string
Azure App Configuration – Access Keys – Primary Connection string

Prepare UI

Currently Teams Toolkit for Visual Studio does neither offer to add additional capabilities / application types (A Tab to an existing Bot or Messaging Extension solution for instance) nor does it add UI components (static HTML pages or Blazor components) although they are needed in a configurable (and also action-based) Message Extension. So this needs to be added manually.

Program.cs

In the Program.cs the following lines need to be added, so staticFiles and Blazor components can be requested / routed:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor(o => o.DetailedErrors = true);
builder.Services.AddControllers();
builder.Services.AddHttpClient("WebClient", client => client.Timeout = TimeSpan.FromSeconds(600));
builder.Services.AddHttpContextAccessor();
...
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
    endpoints.MapFallbackToPage("/_Host");
    endpoints.MapBlazorHub();
});
app.Run();

Blazor basics

Next four basic files for Blazor component routing are needed.

3 basic Blazor files in solution explorer

An App.razor establishing basic routing:

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <p>Sorry, there's nothing at this address.</p>
    </NotFound>
</Router>

Inside, a MainLayout is referenced, which is added in a “Shared” folder as MainLayout.razor

@inherits LayoutComponentBase
@Body

This could be improved further of course but let’s keep it in but simple for now. To have everything together and at hand next an _Imports.razor file is needed, collecting all the required namespaces as @usings

@using System.Net.Http
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MsgextGraphSrchCfg
@using MsgextGraphSrchCfg.Shared

_Host page

As basic “holder” of all developed Blazor components acts by default the _Host.cshtml

@page "/"
@namespace MsgextGraphSrchCfg.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}
<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width" />
  <title>@ViewBag.Title</title>
  <link href="https://static2.sharepointonline.com/files/fabric/office-ui-fabric-core/11.0.0/css/fabric.min.css" rel="stylesheet"/>
  https://res.cdn.office.net/teams-js/2.0.0/js/MicrosoftTeams.min.js
  http://js/MsgExtCfg.js
  <link href="MsgextGraphSrchCfg.styles.css" rel="stylesheet" />
</head>
<body>
  <component type="typeof(App)" render-mode="ServerPrerendered" />
  http://_framework/blazor.server.js
  https://unpkg.com/@@fluentui/web-components
</body>
</html>

Foremost, here some general scripts like FluentUI, TeamsJS SDK or blazor.server.js are loaded but also a custom script is placed here (content explained later). Furthermore, the above mentioned routing App.razor component is rendered here.

Update: In a more up-to-date post I also made a more up-to-description and this post can be found here.

The configuration page

As the Message Extension was already enabled for configuration, there are only two questions left. How the page is requested and how it is rendered.

The first one happens inside the TeamsActivityHandler as the Message Extensions’ Bot is asked for the page, once “Settings” is clicked:

protected override Task<MessagingExtensionResponse> OnTeamsMessagingExtensionConfigurationQuerySettingUrlAsync(ITurnContext<IInvokeActivity> turnContext, MessagingExtensionQuery query, CancellationToken cancellationToken)
    {
        var response = new MessagingExtensionResponse();
        MessagingExtensionResult results = new MessagingExtensionResult
        {
            Type = "config",
            SuggestedActions = new MessagingExtensionSuggestedAction
            {
                Actions = new List<CardAction>
                {
                    new CardAction
                    {
                        Type = ActionTypes.OpenUrl,
                        Value = $"https://51df-46-128-193-33.ngrok.io/Config",
                    }
                }
            }
        };
        response.ComposeExtension = results;
        return Task.FromResult(response);
    }

As a page named Config is requested this can be created as Config.razor component:

@page "/config"
@inject IConfiguration configuration;
<h3>Search Config in Azure App configuration</h3>
<div>
  <div class="siteIDField">
    <FluentTextField @bind-Value="@siteID" 
                      Appearance="Appearance.Filled" 
                      Placeholder="Enter a site id" 
                      Required="true" 
                      id="txtSiteID">Site ID</FluentTextField>
  </div>
  <div class="listIDField">
    <FluentTextField @bind-Value="@listID" 
                      Appearance="Appearance.Filled"
                      Placeholder="Enter a list id" 
                      Required="true" 
                      id="txtListID">List ID</FluentTextField>
  </div>
</div>
<p />
<FluentButton onclick="MsgExtCfg.Config.saveConfig()" Appearance="Appearance.Accent">Save</FluentButton>
@code {
    string siteID;
    string listID;
    protected override Task OnInitializedAsync()
    {
        siteID = configuration["MsgExtGraphActCfg:Settings:SiteID"];
        listID = configuration["MsgExtGraphActCfg:Settings:ListID"];
        return base.OnInitializedAsync();
    }
}

The config page finally looks like this:

So the component renders two <FluentTextField /> which retrieve their values from the Configuration. Additionally, a <FluentButton /> is responsible to write back the values via a JavaScript method.

So how does this work, first in the Bot (where JS sends back the settings, see next) and then with the Azure App Configuration itself.

Code – Retrieve Configuration Settings

Configuration settings are first retrieved by the Bot (after client’s Button JavaScript was executed) and so with its TeamsActivityHandler. Here simply some JSon handling takes place to extract both relevant config values and with a Helper class (see next) they are written to Azure App Configuration storage.

protected override async Task OnTeamsMessagingExtensionConfigurationSettingAsync(ITurnContext<IInvokeActivity> turnContext, JObject settings, CancellationToken cancellationToken)
{
        string state = settings["state"].ToString();
        if (!String.IsNullOrEmpty(state))
        {
            JObject stateJson = JObject.Parse(state);
            string siteID = stateJson["siteID"].Value<string>();
            string listID = stateJson["listID"].Value<string>();
            AzureHelper azureAccess = new AzureHelper(_configuration);
            azureAccess.storeConfigValue("MsgExtGraphActCfg:Settings:SiteID", siteID);
            azureAccess.storeConfigValue("MsgExtGraphActCfg:Settings:ListID", listID);
            string sentinel = azureAccess.GetConfigurationValue("MsgExtGraphActCfg:Settings:Sentinel");
            long newSentinel = long.Parse(sentinel);
            newSentinel++;
            azureAccess.storeConfigValue("MsgExtGraphActCfg:Settings:Sentinel", newSentinel.ToString());
        }
}

The sentinel finally is a trigger so the IConfiguration dynamically reloads after ALL other config values are stored. Much better than a reload would take place on any write update.

Code – Write to App Configuration

As seen above, there is a simple Helper class accessing (in this case mainly writing) Azure App Configuration. The class itself is pretty straightforward. Needs to establish a client and then write values to corresponding keys:

public class AzureHelper
{
        private readonly ConfigurationClient _client;
        public AzureHelper(IConfiguration config) 
        {
            string _connectionString = config["AZURE_CONFIG_CONNECTION_STRING"];
            _client = new ConfigurationClient(_connectionString);
        }
        public void storeConfigValue(string key, string value)
        {
            _client.SetConfigurationSetting(key, value);
        }
}

A bit sensitive is the AZURE_CONFIG_CONNECTION_STRING. This includes a token, so some kind of credential information that grants access. For the moment let’s keep it simple and as is but there are better ways to store/access those kind of information (Key Vault, Managed Identity).

"Endpoint=https://MyConfigStore.azconfig.io;Id=/nk1-l9-s0:JJR***rRLdzf;Secret=wX+jBL/p0**B/3Z8SGVP8***K84nuw="

Code – Read App Configuration

As there was a .SetConfigurationSetting(key, value) you might also expect a .GetConfigurationSetting(key) and you are right. But for some reasons I decided to do it different and load the configuration values from the start and pull dynamically at runtime (as the user can change the values and those should take effect immediately).

This is setup in the Program.cs

// Azure App Configuration
string connectionString = builder.Configuration.GetSection("AZURE_CONFIG_CONNECTION_STRING")?.Value;
// Load configuration from Azure App Configuration
builder.Configuration.AddAzureAppConfiguration(options =>
{
     options.Connect(connectionString)
         // Load all keys that start with `TestApp:` and have no label
         .Select("MsgExtGraphActCfg:Settings:*", LabelFilter.Null)
         // Configure to reload configuration if the registered sentinel key is modified
         .ConfigureRefresh(refreshOptions =>
             refreshOptions.Register("MsgExtGraphActCfg:Settings:Sentinel", refreshAll: true));
});
// Add Azure App Configuration middleware to the container of services.
builder.Services.AddAzureAppConfiguration();
// Add AzureAppConfigurationSettings
builder.Services.Configure<MsgextGraphSrchCfg.Models.Settings>(builder.Configuration.GetSection("MsgExtGraphActCfg:Settings"));
// Use Azure App Configuration middleware for dynamic configuration refresh.
app.UseAzureAppConfiguration();

At first, a connectionString is needed (Endpoint=…, see above or below). With that it’s connected and all values from the app’s config namespace (“MsgExtGraphActCfg:Settings:*”) are selected. Finally a “Sentinel” config value is registered for the ONLY ONE to trigger a dynamic refresh. That means on EVERY write operation this should finally occur as well. But only ONCE for a bunch of updates.

So, as in above’s Config.razor component also in the Message Extension’s Bot component the configuration values will simply be retrieved from the IConfiguration:

protected override async Task<MessagingExtensionResponse> OnTeamsMessagingExtensionQueryAsync(ITurnContext<IInvokeActivity> turnContext, MessagingExtensionQuery query, CancellationToken cancellationToken)
{
        var state = query.State; // Check the state value
        var tokenResponse = await GetTokenResponse(turnContext, state, cancellationToken);
        if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.Token))
        {
            // There is no token, so the user has not signed in yet.
            // Omitted here, see details in GitHub repo
        }
        GraphClient client = new GraphClient(tokenResponse.Token);
        Document[] docs = await client.GetDocuments(_configuration["MsgExtGraphActCfg:Settings:SiteID"], _configuration["MsgExtGraphActCfg:Settings:ListID"]);

Managed Identity to access Azure App Configuration

As said above, dealing with sensitive credentials or similar things in normal app configuration is no good idea. So also an endpoint including a secret such as

"Endpoint=https://MyConfigStore.azconfig.io;Id=/nk1-l9-s0:JJR***rRLdzf;Secret=wX+jBL/p0**B/3Z8SGVP8***K84nuw="

should be kept more secure. Luckily there is a much better alternative inside Azure: Managed Identities. This should be the way to go (not only) when two Azure components access each other.
In short: You can establish a Managed Identity to your App Service (hosting your application), 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.

Add Managed Identity to Azure App Service of current App
Add Managed Identity to Azure App Service of current App

Having that, this new Identity can get access to the App Configuration:

Grant RBAC "App configuration Data Owner" to Managed Identity
Grant RBAC “App configuration Data Owner” to Managed Identity

It’s important to use “App configuration Data Owner” as the more limited “App configuration Data Reader” would not allow to write values.

In code now we need two changes. Those are exactly the two points where previously only the “endpoint” was used to establish a connection. As the endpoint previously included a secret no additional credentials were necessary. This is different now but to be flexible, lets decide on the configuration value what access to use.

In Program.cs it should look like this:

if (connectionString.StartsWith("Endpoint")) {
    builder.Configuration.AddAzureAppConfiguration, 4(options =>
    {
        options.Connect(connectionString)
               // Load all keys that start with `TestApp:` and have no label
               .Select("MsgExtGraphActCfg:Settings:*", LabelFilter.Null)
               // Configure to reload configuration if the registered sentinel key is modified
               .ConfigureRefresh(refreshOptions =>
                    refreshOptions.Register("MsgExtGraphActCfg:Settings:Sentinel", refreshAll: true));
    });
}
else
{
    builder.Configuration.AddAzureAppConfiguration(options =>
    {
        options.Connect(new Uri(connectionString), new ManagedIdentityCredential())
               // Load all keys that start with `TestApp:` and have no label
               .Select("MsgExtGraphActCfg:Settings:*", LabelFilter.Null)
               // Configure to reload configuration if the registered sentinel key is modified
               .ConfigureRefresh(refreshOptions =>
                    refreshOptions.Register("MsgExtGraphActCfg:Settings:Sentinel", refreshAll: true));
    });
}

At first if the config value starts with “Endpoint” its obvious there is one with a secret and the connection can be established as already known (see above). Otherwise there will be (needed) a simple https:// endpoint such as

https://MyConfigStore.azconfig.io

to the App Configuration resource and additionally a ManagedIdentityCredential is provided. For the ManagedIdentityCredential another nuget package, the Azure.Identity, is needed.

And in the AzureHelper.cs class it looks like this:

public AzureHelper(IConfiguration config) 
        {
            string _connectionString = config["AZURE_CONFIG_CONNECTION_STRING"];
            if (_connectionString.StartsWith("Endpoint"))
            {
                _client = new ConfigurationClient(_connectionString);
            }
            else
            {
                _client = new ConfigurationClient(new Uri(_connectionString), new ManagedIdentityCredential());
            }
}

The rest of the solution is the standard behavior of a search-based Message Extension in Teams. The documents retrieved from the given (configured by SiteID and ListID) list are returned and once one is selected, it’s inserted in the compose box of the current conversation. Details on SSO I explained in the past for a similar solution in NodeJS and might follow here for a Teams Toolkit solution with Visual Studio soon.

Message Extension in action

Meanwhile you can have a look in the complete solution’s 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.
File conversion in a Teams Tab with Microsoft Graph and Teams Toolkit for Visual Studio (C#)

File conversion in a Teams Tab with Microsoft Graph and Teams Toolkit for Visual Studio (C#)

Recently I started to create Microsoft Teams App samples with the Teams Toolkit and Visual Studio using C# as backend programming language for instance. Here is a new sample, which extends the last one by using Microsoft Graph’s amazing beta capability to convert numerous source file types to various target (PDF, HTML, JPG, GLB) types. User can select a desired target type, drag a supported source file type onto the app and will receive a converted file at a given target location. Alternatively user now might also switch to a file input for upload a file or more.

Content

Setup

First you need to setup your Teams application. You should have Visual studio 2022 in the version 17.3 or above installed. This enables you to install Teams Toolkit in GA version. Then a Teams App, here a Tab, can be created. This was already described in another post so I won’t repeat it here again.

After the Teams App Dependencies were prepared a basic app registration was created for you. Next there is a need to slightly adjust permissions according to this solution.

Azure App registration

The app registration created is already pre-configured to support the Teams SSO process. The following steps can already be found after “Prepare Teams App Dependencies” (see above):

  • A redirect uri https://localhost/blank-auth-end.html
  • Multi-tenant enabled
  • A client secret
  • An exposed Api “access_as_user” and App ID Uri api://localhost/
  • With the client IDs for Teams App and Teams Web App 1fec8e78-bce4-4aaf-ab1b-5451cc387264 and 5e3ce6c0-2b1f-4285-8d4b-75ee78787346

Additionally for the upload and download operations ReadWrite permissions to OneDrive and SharePoint are needed.

Add necessary Graph delegated app permissions Files.ReadWrite and Sites.ReadWrite.All
Add necessary Graph delegated app permissions

Client-Side

Drag&Drop

The drag&drop handling needs to be enabled for a specific component and also visualized by highlighting the “landing zone”. Therefore, different event receivers are set and implemented in Blazor or JavaScript.

<div class="switch">
    <FluentSwitch @bind-Value=showUpload>Use File Upload</FluentSwitch>
  </div>
  @if (!showUpload)
  {
      <div id="dropFile" class="dropZoneBG">
          Drag your file here:
          <div class="dropZone"
                ondragenter="TabFile.Drag.enableHighlight(event)"
                ondragleave="TabFile.Drag.disableHighlight(event)"
                ondragover="TabFile.Drag.allowDrop(event)"
                ondrop="TabFile.executeUpload(event)">
              <i id="fileIcon" class="ms-Icon ms-Icon--PDF pdfLogo" aria-hidden="true"></i>
          </div>
      </div>
  } 
TabFile.Drag = {};
{
    TabFile.Drag.allowDrop = function (event) {
      event.preventDefault();
      event.stopPropagation();
      event.dataTransfer.dropEffect = 'copy';
    }
    TabFile.Drag.enableHighlight = function (event) {
      TabFile.Drag.allowDrop(event);
      const bgDIV = document.getElementsByClassName('dropZone');
      bgDIV[0].classList.add('dropZoneHighlight');
    }
    TabFile.Drag.disableHighlight = function (event) {
      TabFile.Drag.allowDrop(event);
      const bgDIV = document.getElementsByClassName('dropZone');
      bgDIV[0].classList.remove('dropZoneHighlight');
    }
}

File upload

Instead of the drag&drop zone with the <FluentSwitch /> above, the user can instead display a file upload part. Makes sense, especially on mobile devices which do not support drag&drop for instance. The initial event receiver on the button is different but finally this and the one above onDrop will come to the same function (see later below).

@if (showUpload)
{
    <div id="inputFile" class="fileInput">
      <label for="myfile">Select a file:</label>
      <input type="file" id="myfile" name="myfile">
      <FluentButton onclick="TabFile.inputUpload()">Upload</FluentButton>
    </div>
} 
Switch between drag&drop and file upload option
Switch between drag&drop and file upload option

Target file type

As now there are more than on option (PDF) as the target type there is the need to provide a choice to the user. Once a target is picked, the supported source types are shown as well (and validated on upload ☝🏻)

Select the target file type in a drop-down ​
Select the target file type in a drop-down

Progress Indicator

As the conversion process consists of 3 major up-/download operations and may take a while showing a progress indicator to the user is a good and polite idea. Therefore, usage of a FluentUI Blazor component is a suitable idea:

<div style="display: none;" class="loader">
    <FluentProgressRing />
</div>

This is enabled by the following <script /> tag in the root HTML page:

<script type="module" src="https://unpkg.com/@@fluentui/web-components"></script>

The two mentioned event receivers on the “Upload” button, respectively on the onDrop event only produce an Array of files (as there could be more than one):

TabFile.inputUpload = function (event) {
    const input = document.getElementById('myfile');
    const files = Array.prototype.slice.call(input.files);
    TabFile.uploadFiles(files);
}
TabFile.executeUpload = function (event) {
    TabFile.Drag.allowDrop(event);    
    TabFile.Drag.disableHighlight(event);
    const dt = event.dataTransfer;
    const files = Array.prototype.slice.call(dt.files); // [...dt.files];
    TabFile.uploadFiles(files);
}

The files are then handed over to the “real” upload function finally responsible to call the backend service:

In the JavaScript code at the beginning of the upload operation the whole <div/> containing the progress indicator is made visible while it’s turned off again at the very end.

TabFile.uploadFiles = function (files) {
    const loaderDIV = document.getElementsByClassName('loader')[0];
    loaderDIV.style.display = 'flex';
    files.forEach(fileToUpload => {
      const extensions = fileToUpload.name.split('.');
      const fileExtension = extensions[extensions.length - 1];
      if (TabFile.Utilities.isFileTypeSupported(fileExtension, TabFile.selectedFilyType)) {
        const formData = new FormData();
        formData.append('file', fileToUpload);
        formData.append('Name', fileToUpload.name);
        formData.append('SiteUrl', TabFile.siteUrl);
        formData.append('TargetType', TabFile.selectedFilyType);
        fetch("/api/Upload", {
          method: "post",
          headers: {
            "Authorization": "Bearer " + TabFile.ssoToken
          },
          body: formData
        })
          .then((response) => {
            response.text().then(resp => {
              TabFile.addConvertedFile(resp);
              loaderDIV.style.display = 'none';
            });
          });
      }
      else {
        alert('File type not supported!')
      }
    });
}

In between the file and several parameters are collected in a form and with it the backend is called. Authentication is done with a bootstrap token (generation see next).

SSO

(function (TabPDF) {
  ssoToken = "";
  siteUrl = "";
  TabPDF.getSSOToken = function () {
    if (microsoftTeams) {
      microsoftTeams.initialize();
      microsoftTeams.authentication.getAuthToken({
        successCallback: (token, event) => {
          TabPDF.ssoToken = token;
        },
        failureCallback: (error) => {
          renderError(error);
        }
      });
    }
  }
}
}(window.TabPDF = window.TabPDF || {}));

This is a self invoking function that stores itself in a global variable. The TabPDF.getSSOToken function is executed onload of the whole script

<script onload="(function () { TabPDF.getSSOToken(); TabPDF.getContext();}).call(this)" src="/js/TabPDF.js">

The function uses the TeamsJS SDK 2.0 to get an AuthToken. That is, the so called ID or bootstrap token which needs to be exchanged to an access token for getting access to Microsoft Graph. But this happens later server-side. For the moment it is stored in a class variable where it’s taken from “on action” when a file is dropped.

Additionally TeamsJS SDK 2.0 provides either the SharePoint siteUrl for the current Teams’ site (if Tab is running as a Configurable inside a team) or the SharePoint host url (if Tab is running as static or personal one). That siteUrl will later be used as the target siteUrl for final upload of the converted PDF.

TabPDF.getContext = function () {
    if (microsoftTeams) {
      microsoftTeams.app.getContext()
        .then(context => {
          if (context.sharePointSite.teamSiteUrl !== "") {
            TabPDF.siteUrl = context.sharePointSite.teamSiteUrl;
          }
          else {
            TabPDF.siteUrl = "https://" + context.sharePointSite.teamSiteDomain;
          }
        });
    }
  }

Alternatively, a dedicated site url could be stored with the configuration option. This is another story and for Teams Toolkit and Visual Studio I will handle later.

Backend Graph Controller

Authentication / SSO

In the call above the bootstrap token was used for authentication with “Bearer “. Any backend controller in our solution now simply gets added UI authentication and additionally a Microsoft Graph client established by automatic use of the on-behalf flow. This is done in the program.cs

builder.Services
    // Use Web API authentication (default JWT bearer token scheme)
    .AddMicrosoftIdentityWebApiAuthentication(builder.Configuration, "TeamsFx:Authentication")
    // Enable token acquisition via on-behalf-of flow
    .EnableTokenAcquisitionToCallDownstreamApi()
    // Add authenticated Graph client via dependency injection
    .AddMicrosoftGraph(builder.Configuration.GetSection("Graph"))
    // Use in-memory token cache
    // See https://github.com/AzureAD/microsoft-identity-web/wiki/token-cache-serialization
    .AddInMemoryTokenCaches();

The Microsoft Graph SDK client can then directly be used inside our controller. Also the _tokenAcquisition to generate another token. This will be useful as there is the need to instantiate another HttpClient Controller (see later).

[Route("api/[controller]")]
[ApiController]
public class UploadController : ControllerBase
{
    private readonly GraphServiceClient _graphClient;
    private readonly ITokenAcquisition _tokenAcquisition;
    private readonly ILogger<UploadController> _logger;
    public UploadController(ITokenAcquisition tokenAcquisition, GraphServiceClient graphClient, ILogger<UploadController> logger)
    {
        _tokenAcquisition = tokenAcquisition;
        _graphClient = graphClient;
        _logger = logger;
    }
...

Temporary upload to OneDrive

The backend operation first takes the file and the other parameters, and uploads the file directly to OneDrive for a temporary purpose.

public async Task<ActionResult<string>> Post([FromForm] UploadRequest fileUpload)
{
    string userID = User.GetObjectId();
    DriveItem uploadResult = await uploadTempFile(userID, fileUpload);

Then a temporary file upload to OneDrive occurs. This can be done in two ways. A simple upload if the file is <4MB or a resumable upload session if the file is >4MB.

private async Task<DriveItem> uploadTempFile(string userID, UploadRequest fileUpload)
{
    DriveItem uploadResult;
    if (fileUpload.file.Length < (4 * 1024 * 1024))
    {
        uploadResult = await this._graphClient.Users[userID]
                                                 .Drive.Root
                                                 .ItemWithPath(fileUpload.file.FileName)
                                                 .Content.Request()
                                                 .PutAsync<DriveItem>(fileUpload.file.OpenReadStream());
    }
    else
    {
        Drive drive = await this._graphClient.Users[userID].Drive.Request().Select("id").GetAsync();
        uploadResult = await this.UploadLargeFile(drive.Id, fileUpload.file.FileName, fileUpload.file.OpenReadStream());
    }
    return uploadResult;
}

The resumable upload, which can also later be reused for the final upload of the converted file in case needed (>4MB) looks like this:

private async Task<DriveItem> UploadLargeFile(string driveID, string fileName, Stream fileStream)
{
    var uploadProps = new DriveItemUploadableProperties
    {
        AdditionalData = new Dictionary<string, object>
        {
            { "@microsoft.graph.conflictBehavior", "replace" }
         }
    };
    var uploadSession = await this._graphClient.Drives[driveID].Root
        .ItemWithPath(fileName)
        .CreateUploadSession(uploadProps)
        .Request()
        .PostAsync();
    // Max slice size must be a multiple of 320 KiB
    int maxSliceSize = 320 * 1024;
    var fileUploadTask =
         new LargeFileUploadTask<DriveItem>(uploadSession, fileStream, maxSliceSize);
    var totalLength = fileStream.Length;
    // Create a callback that is invoked after each slice is uploaded
    IProgress<long> progress = new Progress<long>(prog => {
        _logger.LogInformation($"Uploaded {prog} bytes of {totalLength} bytes");
    });
    try
    {
        var uploadResult = await fileUploadTask.UploadAsync(progress);
        Console.WriteLine(uploadResult.UploadSucceeded ?
            $"Upload complete, item ID: {uploadResult.ItemResponse.Id}" :
            "Upload failed");
        return uploadResult.ItemResponse;
     }
      catch (ServiceException ex)
      {
            Console.WriteLine($"Error uploading: {ex.ToString()}");
            return null;
       }
}

Download with file conversion

Having a file uploaded to OneDrive, and received the driveItemID, it is quite simple to download it once again, but this time with the format conversion to the selected file type (PDF, HTML, JPG). Therefore, “?format=<fileType>” is attached to the request.

Unfortunately at the time of writing this, it is not possible with the new Microsoft Graph .NET SDK. And with the old one it is hard to mix v1.0 and beta requests. My personal workaround for this is: Mix Microsoft Graph .NET SDK v1.0 calls with HttpClient calls for beta. So for this conversion call only a HttpClientController is established:

public class HttpClientController : ControllerBase
{
        private readonly string _accessToken;
        private HttpClient _httpClient;
        public HttpClientController(string accessToken)
        {
            _accessToken = accessToken;
            this._httpClient = new HttpClient();
            this._httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer " + accessToken);
        }
        public async Task<HttpResponseMessage> GetConvertedFile(string userID, string itemID, string convertTo, ImageSize? size)
        {
            string url =$"https://graph.microsoft.com/beta/users/{userID}/Drive/Items/{itemID}/content?format={convertTo}";
            if (convertTo== "JPG" && size != null)
            {
                url += $"&width={size.Width}&height={size.Height}";
            }
            Uri uri = new Uri(url);
            try
            {
                var httpResult = await this._httpClient.GetAsync(uri);
                return httpResult;
            }
            catch (HttpRequestException ex)
            {
                Console.WriteLine(ex.Message);
            }
            return null;
        }
}

Final upload to target

Finally, the PDF converted Stream needs to be uploaded to the target siteUrl (definition depending on current context, see above). First, a new pdfFileName needs to be constructed by exchanging former file extension with .pdf

The upload itself is easy (UploadSession and big file sizes see above). After evaluating the siteId based on the given siteUrl the upload itself can be executed and the webUrl of the new DriveItem can be returned for display.

private async Task<string> UploadConvertedFile(string userID, string orgFileName, Stream fileStream, string siteUrl, string convertTo)
{
    string convertedFileName = Path.GetFileNameWithoutExtension(orgFileName);
    convertedFileName += $".{convertTo.ToLower()}";
    string siteId = await EvaluateSiteID(siteUrl); 
    DriveItem uploadResult;
    if (fileStream.Length < (4 * 1024 * 1024))
    {
        uploadResult = await this._graphClient.Sites[siteId]
                                                    .Drive.Root
                                                    .ItemWithPath(convertedFileName)
                                                    .Content.Request()
                                                    .PutAsync<DriveItem>(fileStream);
        }
        else
        {
            Drive drive = await this._graphClient.Sites[siteId]
                                                    .Drive
                                                    .Request()
                                                    .Select("id")
                                                    .GetAsync();
            uploadResult = await this.UploadLargeFile(drive.Id, convertedFileName, fileStream);                
        }
         return uploadResult.WebUrl; 
}
private async Task<string> EvaluateSiteID(string siteUrl)
{
    Uri siteUri = new Uri(siteUrl);
    Site site = await this._graphClient.Sites.GetByPath(siteUri.PathAndQuery, siteUri.Host).Request().GetAsync();
    return site.Id;
}

Delete temporary file

Finally to clean up resources the temporary first upload file to OneDrive can be deleted. That’s quite easy because we still have the driveItem ID.

private void DeleteTempFile(string userID, string itemID)
{
       this._graphClient.Users[userID]
                    .Drive.Items[itemID]
                    .Request()
                    .DeleteAsync();
}

Finally, the app in action looks like the following:

The whole upload and conversion (here to a JPG) process running
The whole upload and conversion (here to a JPG) process running

I hope I explained the essential topics drag&drop in HTML5, Teams SSO, Microsoft Graph file upload and format conversion in an understandable way. Also, you understood once again, the benefit of such a backend solution, when having the need for a chain of upload and download operations. In SPFx I once upon a time realized a similar solution, but that is not the best idea. Nevertheless for your reference, the whole solution 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.