Tuesday, April 20, 2021

Securing a Microsoft Teams Tab using Azure Active Directory

Custom Teams Tabs

Microsoft O365 has expanded the ecosystem of the traditional office suite with many niche tools. Teams is a tool that has evolved out of that niche into a system that helps pull the office apps into one easy to use area. Teams replaces Skype as the primary chat tool, but it also lets you access SharePoint sites, your OneDrive, planner, and other apps. 

The primary way of accessing apps is through tabs. Tabs are just fancy I Frames that let you embed web pages into Teams. One of the benefits of embedding the page into Teams is the ability to access the user's profile data from Azure. We can get a user's UPN and pull general information about a user and the Team where the tab is opened.

Since we can embed custom pages into the tab, we can now have full blown web applications embedded into the O365 environment. This poses a challenge for security; how do I authenticate the user seamlessly with O365 and my web application? The answer is Azure AD. We can secure our application using Azure AD and then use the Microsoft Teams Client SDK to authenticate to our web application using a supplied id token that can be requested in the Teams Tab using the Microsft Teams SDK.

Authentication using Teams

The Microsoft Teams SDK will allow us to request an ID token that can be passed to our web application to secure our application. There are some caveats such as the Microsoft Teams SDK still uses ADAL to authenticate. This means, we are limited to what we can access in Office 365 with the access token that is also provided. This seems par for the course as the SharePoint client object model and REST services use ADAL with no news on upgrading. ADAL also poses an issue with some more modern browsers, as it requires 3rd party cookies to be active. Luckily Teams is in a corporate environment and can be easily controlled.

Another thing to take into account, is that in order to display a page in Teams it must be anonymous. The page needs to load so that it can call the APIs that provide the login information. This means that any secure data must be behind an API call instead of the standard view you would get with an MVC site. You might be able to use a Challenge Result to sign in the user, but from my experience that usually pops up the Microsoft Login page again which can cause issues in the Teams app because you are redirecting in an I Frame which will cause cross scripting errors.


Configure Azure for Authentication

In order to use Azure AD to authenticate our system, we need to register our application with Azure AD. In your Azure portal, go to the Azure AD section and find App Registrations. I will be creating an application called Teams Authentication.






In the new registration screen I will name my app Teams Authentication. Supported accounts will vary based on your requirements for the app. For this example I will choose my organization's directory only. Next I will add a redirect URI. This is the sign in URI for our application, it will be "https://localhost:5001/signin/signinend". This URI will need to be updated once we test as it is where Microsoft will redirect our tokens. Reminder: spelling and casing count! If your URL is all lowercase here it must be all lowercase in the call to access the token.



With your newly registered app, in the overview screen you will see a client ID. This is needed to create a call to authenticate our application. You can also get your tenant ID here as well to make calls directly to the tenant and create a tenant specific token as opposed to using the common login URL. Also, to authenticate to our web application we will need our appplication to return an Access Token and ID token. This can be selected in the authentication section of the app.





For this example, we will also be authenticating with the Graph API. Our token generated from Teams is limited to what it can access from graph, so we will want to create another app that interacts with Microsoft graph itself. We just want to show how you can authenticate to the web application and make a secure call so our application will be using application permissions to access graph. We will only need User.Read.All for this. The process is similar to above, but will require a secret to be generated for the application. I have done this in a previous post here:  https://www.fiveminutecoder.com/2021/03/creating-azure-document-queue-for.html


Create our tab in 5 minutes

With our app registered with Azure AD we can start to setup our application. First thing we need to do is create our MVC application

dotnet new mvc --name OfficeEmployeeDirectory


Our application will use JWT to authenticate, so we need to install the Microsoft identity model, along with the JWT packages. In order to do this, we will need to install the following DLLs from Nuget.

  1. Microsoft.Identity.Web
  2. Microsoft.Identity.Web.UI
  3. Microsoft.IdentityModel.Clients.ActiveDirectory
  4. Microsoft.AspNetCore.Authentication.JwtBearer
  5. Microsoft.AspNetCore.Authentication.AzureAD.UI
  6. Microsoft.Graph

Next, we will setup or app settings file with our app settings from Azure. We will create 2 sections for our apps. In the AzureAD section notice for the tenant I have common setup. This is used to allow multiple tenants access to your site, if you want only one you can put your tenant ID here, which can be found in the app registration overview. Also, the client id begins with api:// this is because the default auth method is 1.0 if you are using a later authentication method this might not be necessary. Audience in this section is the same as your client id.



{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "SignInUrl": "https://localhost:5001/Signin/SigninStart",
  "ReturnUrl": "https://localhost:5001/signin/signinend",
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "https://localhost:5001",
    "ClientId": "api://{client id}",
    "TenantId": "common",
    "CallbackPath": "/signin/signinend",
    "Audience": "{client id}",
    "Scopes" : "access_as_user access_as_admin",
    "AllowWebApiToBeAuthorizedByACL" : true
  },
  "DirectoryApp":{
    "TenantId": "{tenant id}",
    "ClientId": "{client id for graph app}",
    "clientSecret": "{client secret for graph app}"
  }
}

With our configurations setup, we can update our Startup.cs file to now allow an id token to be passed. We will be using JWTBearer authentication to validate our token.


public void ConfigureServices(IServiceCollection services)
{
	
	services.AddControllersWithViews();


	//authentication starts here
	services.AddAuthentication(options =>{
		options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; //using JWT token auth
		
	})
	  .AddMicrosoftIdentityWebApi(Configuration.GetSection("AzureAd")) //auth will act like a web api, otherwise our app tries to popup another login screen which is blocked
	  .EnableTokenAcquisitionToCallDownstreamApi()
	  .AddInMemoryTokenCaches();

	  

	services.AddControllersWithViews(options =>
	{
	  //add authenticated user to secure the app
	  var policy = new AuthorizationPolicyBuilder()
		  .RequireAuthenticatedUser()
		  .Build();
	  options.Filters.Add(new AuthorizeFilter(policy));
	});
	services.AddRazorPages()
	  .AddMicrosoftIdentityUI();
}

Microsoft wants our apps to be transparent, in other words apps require consent. Most of the time this can be granted by the admin, but user consent is required when making calls on the behalf of the users. So in order to login using Azure AD we need to setup login and logout pages that can make calls to Azure AD. There are settings to make this request silently, but takes time to configure and catch those types of calls. So in this case, we will show the login window. To do this we will create a controller called "Signin". In a production setting this would be your authenticate controller but for this blog I changed it so we could understand more what is happening. 

To sign in we have 2 pages SigninStart and SigninEnd, so in order to do this we will need to add some actions to our controller. Please note, these need to be anonymous to make the call.


using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using OfficeEmployeeDirectory.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;

namespace OfficeEmployeeDirectory.Controllers
{
    //just controls the login views routing
    [AllowAnonymous]
    public class SigninController: Controller
    {
        private IConfiguration configuration;
        public SigninController(IConfiguration Configuration)
        {
            configuration = Configuration;
        }

        public ActionResult SigninStart()
        {
            ViewBag.ReturnUrl = configuration.GetValue("ReturnUrl");
            ViewBag.ClientId = configuration.GetSection("AzureAd").GetValue("Audience");
            return View();
        }

        public ActionResult SigninEnd()
        {
            return View();
        }
    }
}


The authentication happens client side so our page will juse the Microsft Teams SDK to call out to Azure.


//calls the teams login
//javascript
	let clientId = "@ViewBag.ClientId";
	if (clientId != undefined && clientId != null && clientId !== '') {
		microsoftTeams.initialize();
			let state = _guid();
			localStorage.setItem("simple.state", state);
			localStorage.removeItem("simple.error");
			// See https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols-implicit
			// for documentation on these query parameters

			let queryParams = {
				client_id: clientId,
				response_type: "id_token token", //what we want returned
				response_mode: "fragment",
				resource: "https://graph.microsoft.com/", //resource we need access to
				redirect_uri: "@ViewBag.ReturnUrl", //return url
				nonce: _guid(),//unique value to reference in callback so our app can validate it was us making the call
				state: state
			};

			let authorizeEndpoint =
				"https://login.microsoftonline.com/common/oauth2/authorize?" +
					toQueryString(queryParams);
			window.location.assign(authorizeEndpoint);

	}
	// Build query string from map of query parameter
	function toQueryString(queryParams) {
		let encodedQueryParams = [];
		for (let key in queryParams) {
			encodedQueryParams.push(
				key + "=" + encodeURIComponent(queryParams[key])
			);
		}
		return encodedQueryParams.join("&");
	}
	
	//Create a unique identifier to validate the callback
	function _guid() {
			let guidHolder = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
			let hex = "0123456789abcdef";
			let r = 0;
			let guidResponse = "";
			for (let i = 0; i < 36; i++) {
				if (guidHolder[i] !== "-" && guidHolder[i] !== "4") {
					// each x and y needs to be random
					r = (Math.random() * 16) | 0;
				}
				if (guidHolder[i] === "x") {
					guidResponse += hex[r];
				} else if (guidHolder[i] === "y") {
					// clock-seq-and-reserved first hex is filtered and remaining hex values are random
					r &= 0x3; // bit and with 0011 to set pos 2 to zero ?0??
					r |= 0x8; // set pos 3 to 1 as 1???
					guidResponse += hex[r];
				} else {
					guidResponse += guidHolder[i];
				}
			}
			return guidResponse;
	}


The call will then return a URL for our site that can is used to either show an error or return our token. We will use the Microsoft Teams SDK to return our tokens back to the calling page.


//sign in attempt finished, will parse query strings and save tokens to object
//javascript
	microsoftTeams.initialize();
	
	localStorage.removeItem("simple.error");
	
	let hashParams = getHashParameters();
	
	if (hashParams["error"]) {
		// Authentication/authorization failed
		localStorage.setItem("simple.error", JSON.stringify(hashParams));
		microsoftTeams.authentication.notifyFailure(hashParams["error"]); //notifies our main page of an issue
	} else if (hashParams["access_token"]) {
		// Get the stored state parameter and compare with incoming state
		let expectedState = localStorage.getItem("simple.state");
		if (expectedState !== hashParams["state"]) {
			// State does not match, report error
			localStorage.setItem("simple.error", JSON.stringify(hashParams));
			microsoftTeams.authentication.notifyFailure("StateDoesNotMatch");
		} else {
			// Success -- return token information to the parent page
			microsoftTeams.authentication.notifySuccess({
				idToken: hashParams["id_token"],
				accessToken: hashParams["access_token"],
				tokenType: hashParams["token_type"],
				expiresIn: hashParams["expires_in"]
			});
		}
	} else {
		// Unexpected condition: hash does not contain error or access_token parameter
		localStorage.setItem("simple.error", JSON.stringify(hashParams));
		microsoftTeams.authentication.notifyFailure("UnexpectedFailure");
	}


	// Parse hash parameters into key-value pairs
	function getHashParameters() {
		let hashParams = {};
		location.hash.substr(1).split("&").forEach(function (item) {
			let s = item.split("="),
				k = s[0],
				v = s[1] && decodeURIComponent(s[1]);
			hashParams[k] = v;
		});
		return hashParams;
	}


We have our authentication ready, so the next step is to create an secure end point to call. I just added this to the Home index controller for brevity, this should be on a sperate controller with a /api route.


//our secure endpoint
[HttpGet]
public async Task GetDirectory()
{
	try
	{
		//pulls config data for our graph api
		string tenantId = configuration.GetSection("DirectoryApp").GetValue("TenantId"); //realm
		//some service account with graph api permissions
		string clientId = configuration.GetSection("DirectoryApp").GetValue("ClientId"); 
		//service account password
		string clientSecret = configuration.GetSection("DirectoryApp").GetValue("ClientSecret");; 
		string[] scopes = new string[] {"https://graph.microsoft.com/.default" };

		//creates a header for accessing our graph api
		IConfidentialClientApplication app = ConfidentialClientApplicationBuilder.Create(clientId)
				.WithClientSecret(clientSecret)
				.WithAuthority(new Uri("https://login.microsoftonline.com/" + tenantId))
				.Build();

		//gets token
		AuthenticationResult result = await app.AcquireTokenForClient(scopes).ExecuteAsync();

		//creates graph client
		GraphServiceClient client = new GraphServiceClient("https://graph.microsoft.com/v1.0", new DelegateAuthenticationProvider(
		async (requestMessage) =>
		{
			//adds token to header
			requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", result.AccessToken);
		}
		));


		//pulls all users who's account is enabled
		var users = await client.Users.Request().Filter("AccountEnabled eq true").GetAsync();

		return Json(users);

	}
	catch(Exception ex)
	{
		return BadRequest(ex.Message);
	}
}

Finally, our app is ready to call our endpoint, on our home page, we will use the Microsoft Teams SDK to call our start page, then we will take the results and call our secured endpoint using the token.


@{
    ViewData["Title"] = "Employee Directory";
}

//styling
    .card{
        width:400px;
        height:200px;
        border:1px solid black;
        margin-bottom: 20px;
        padding: 5px;
    }

Employee Directory

label id="jsonResponse" label //script //run our script when window loads window.addEventListener("load", function(){ //load the microsoft teams SDK microsoftTeams.initialize(); //params for our login method authenticateParams = { successCallback: function(result){ var token = result["idToken"]; var access_token = result["accessToken"]; GetEmployeeDirectory(token); }, failureCallback: function(reason){ alert("failed: " + reason); }, height: 200, width: 200, url: "@ViewBag.SignInUrl" } //start authentication process microsoftTeams.authentication.authenticate(authenticateParams); }); //success callback to our secure api function GetEmployeeDirectory(token){ fetch("/Home/GetDirectory", { method: "GET", headers: { "content-type": "application/json;odata=verbose", "Accept": "application/json; odata=verbose", "Authorization": "Bearer " + token } }) .then(response =>response.json()) .then(results =>{ // creates an html object to be rendered in our app for(var i = 0; i < results.length; i++){ let div = document.createElement("div") div.className = "card" div.append(results[i].displayName); div.append(document.createElement("br")) div.append( results[i].mail) div.append(document.createElement("br")) div.append(results[i].businessPhones.length > 0 ? results[i].businessPhones[0] : document.createElement("label")) document.getElementById("jsonResponse").append(div); } }); }

Testing our Tab in Microsoft Teams

If everything is setup correctly and you run the application you will not get the expected results. You will see the page pop up to login, close, and get an error stating "failed: CancelledByUser". This is because the application is not running in Microsoft Teams. In order to test our application we need to run it in Teams so we can pull the user context.




To test this in Teams, we need a public URL. We could publish our site to Azure, but that makes debugging difficult. So we will use a tool called NGROK, which can be found here, to create a tunnel to our local application. To get a public URL run the command below in the NGROK window.



ngrok http --host-header=rewrite 5000







With our public URL, we need to go back into Azure and update our application return URL. Remember casing counts here if it doesn't match what is in our application it will give an error. 





Next we need to update the application Return URL to match our new NGROK URL.






With our application ready, we can now open Teams and configure our app. Teams has an app called "App Studio" that allows us to create an XML config file for our app. 





Walking through the App config details page and fill in your information, it is straight forward. You can find mine in the code project. Next click the tab capabilities, we will add our URL's here. Click the new personal tab button and fill in the fields, for the Content URL use the NGROK URL. Everytime you make a change to the NGROK URL, you will need to update this manifest.






Now we can install the application, I am using the web client so I will install it using Publish. If you have the desktop client, you can side load applications. To do so, click Test and Distribute in app studio install or publish in your tenant.

You should now be able to use your app, click the ellipse below files to find your application. If it is not there, at the bottom of teams you will find Apps, search for the app there and install it.

That's It! We now have a secure Teams tab using Azure AD.





Clone the project


You can find the full project on my GitHub site here https://github.com/fiveminutecoder/blogs/tree/master/OfficeEmployeeDirectory

Microsoft, Teams, Microsoft Teams, C#,C Sharp, Authentication, JWT, id token, access token, token, Teams Authentication, Microsoft teams SDK, teams sdk, Teams App, Teams Tab, five minute coder, Microsoft Graph, Azure AD, Azure Active Directory, Azure App, application,

No comments:

Post a Comment

C#, C sharp, machine learning, ML.NET, dotnet core, dotnet, O365, Office 365, developer, development, Azure, Supervised Learning, Unsupervised Learning, NLP, Natural Language Programming, Microsoft, SharePoint, Teams, custom software development, sharepoint specialist, chat GPT,artificial intelligence, AI

Cookie Alert

This blog was created and hosted using Google's platform Blogspot (blogger.com). In accordance to privacy policy and GDPR please note the following: Third party vendors, including Google, use cookies to serve ads based on a user's prior visits to your website or other websites. Google's use of advertising cookies enables it and its partners to serve ads to your users based on their visit to your sites and/or other sites on the Internet. Users may opt out of personalized advertising by visiting Ads Settings. (Alternatively, you can opt out of a third-party vendor's use of cookies for personalized advertising by visiting www.aboutads.info.) Google analytics is also used, for more details please refer to Google Analytics privacy policy here: Google Analytics Privacy Policy Any information collected or given during sign up or sign is through Google's blogger platform and is stored by Google. The only Information collected outside of Google's platform is consent that the site uses cookies.