Web Application – Azure AD Single Sign On

Previously I’ve used Azure AD to authenticate web application users as well as to retrieve authentication tokens for accessing Azure resources but I hadn’t tried retrieving tokens during the initial login before.

It turns out that this was much trickier than I’d expected, partially because of how abstracted away the authentication flow now is within NuGet packages and partially because of a paucity of documentation.

I eventually managed to figure out how to request an access token for Power BI resources at the point of initial login, mostly thanks to this Azure sample on GitHub.

I’ve created a sample project to demonstrate how this is done here, though the principle should work for other Azure resources such as the Microsoft Graph API.

Below is a brief summary of it all works but this doesn’t contain all the required code so check out the GitHub project for a complete project.

Setup

This project is based around displaying Power BI dashboards belonging to a user in a web application so a web application and Power BI resources are obviously prerequisites. The simplest way to create and register the web application in Azure AD is through Visual Studio as I’ve previously detailed. Power BI resources can be created in the web portal, I just created a couple of dashboards from sample data for this proof of concept.

Once created you need to grant the app to your Power BI resources, you can probably narrow down the scope of the permissions a bit but the ones I granted are below.

  • Read and Write all Reports
  • View users Groups
  • Create content (preview)
  • Read and Write all Datasets
  • View all Dashboards (preview)
  • Read and Write all Dashboards
Code

Although Visual Studio scaffolds your newly created project with the code needed for Azure AD authentication at the login stage it needs to be extended in order to also request and store the desired access tokens, to do this you’ll need to install the Microsoft.Identity.Client NuGet library which is currently available as a prerelease version.

In order to acquire the relevant access tokens you need to add a TokenAcquisitionExtension class, this creates the service extension method and allows tokens to be accessed throughout the project through dependency injection.

We need to specify the authority when we’re creating the ConfidentialClientApplication object in this class as by default ConfidentialClientApplication tries to authenticate using Azure AD v2 and Power BI is a v1 app, currently I think it’s just the Microsoft Graph API that’s available in v2.

This solves the following error which I was getting when not specifying the authority.

Application '{application id}' (aad name) is not supported over the /common or /consumers endpoints. Please use the /organizations or tenant-specific endpoint.

This is demonstrated in the below method in my TokenAcquisitionExtension class where the authority is specified with the desired tenant ID.

	
public async Task AddAccountToCacheFromAuthorizationCode(AuthorizationCodeReceivedContext context, IEnumerable<string> scopes)
{
	if (context == null)
		throw new ArgumentNullException(nameof(context));

	if (scopes == null)
		throw new ArgumentNullException(nameof(scopes));

	try
	{
		// Acquiring a token with MSAL using the Authorization code flow in order to populate the token cache
		var request = context.HttpContext.Request;
		var currentUri = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path);
		var credential = new ClientCredential(_azureAdOptions.ClientSecret);
		Application = new ConfidentialClientApplication(_azureAdOptions.ClientId, "https://login.microsoftonline.com/" + _azureAdOptions.TenantId + "/oauth2/", currentUri, credential, AuthPropertiesTokenCacheHelper.ForCodeRedemption(context.Properties), null);
		var result = await Application.AcquireTokenByAuthorizationCodeAsync(context.ProtocolMessage.Code, scopes);
		context.HandleCodeRedemption(result.AccessToken, result.IdToken);
	}
	catch (MsalException ex)
	{
		string message = ex.Message;
		throw;
	}
}
	

The v2 API is still used for authentication but v1 APIs can be used through it if they’re specified in the required scopes.

I set this manually in the Configure method of my AzureAdAuthenticationBuilderExtensions class but a better option would be to do this via the initial AzureAd config object.

	
public void Configure(string name, OpenIdConnectOptions options)
{
	options.ClientId = _azureOptions.ClientId;
	options.Authority = $"{_azureOptions.Instance}{_azureOptions.TenantId}/v2.0";   // V2 specific
	options.UseTokenLifetime = true;
	options.RequireHttpsMetadata = false;
	options.TokenValidationParameters.ValidateIssuer = false;     // accept several tenants
	options.Events = new OpenIdConnectEvents();
	options.Events.OnAuthorizationCodeReceived = OnAuthorizationCodeReceived;
	// Request Power BI rights as well as standard user read rights
	options.Scope.Add("user.read https://analysis.windows.net/powerbi/api/Dataset.Read.All");
	options.ResponseType = "code id_token";
}
	

As per my previous post, when the authentication object is returned and stored as a cookie it is actually too long to be stored in the request header and will generate the following error.

Bad Request - Request Too Long
HTTP Error 400. The size of the request headers is too long.

This can be fixed by storing cookies in a memory cache rather than in the request headers.

These options are all specified in the Startup file like so.

	
public void ConfigureServices(IServiceCollection services)
{
	services.Configure<CookiePolicyOptions>(options =>
	{
		// This lambda determines whether user consent for non-essential cookies is needed for a given request.
		options.CheckConsentNeeded = context => false;
		options.MinimumSameSitePolicy = SameSiteMode.None;
	});

	// Add session
	services.AddSession();

	services.AddAuthentication(sharedOptions =>
	{
		sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
		sharedOptions.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
	})
	.AddAzureAd(options => Configuration.Bind("AzureAd", options))
	.AddCookie(options =>
	{
		options.SessionStore = new MemoryCacheTicketStore();
	});

	// Token service
	services.AddTokenAcquisition();

	services.AddMvc(options =>
	{
		var policy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
			.Build();
		options.Filters.Add(new AuthorizeFilter(policy));
	})
	.SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
}
	

The requested token is then stored in the TokenCache and can be accessed elsewhere via dependency injection.

	
public IActionResult PowerBi(string dashboardId)
{
	if (!string.IsNullOrEmpty(dashboardId))
	{
		HttpContext.Session.SetString("DashboardId", dashboardId);
		TempData["DashboardId"] = dashboardId;
	}

	// Retrieve token from cache
	string accessToken = _tokenAcquisition.GetAccessTokenOnBehalfOfUser(HttpContext, User, new string[] { "https://analysis.windows.net/powerbi/api/Dataset.Read.All" }).Result;

	string responseContent = string.Empty;

	//Configure dashboards request
	System.Net.WebRequest request = System.Net.WebRequest.Create(String.Format("{0}/dashboards", _config["PowerBi:ApiUri"].ToString())) as System.Net.HttpWebRequest;
	request.Method = "GET";
	request.ContentLength = 0;
	request.Headers.Add("Authorization", String.Format("Bearer {0}", accessToken));

	PowerBIDashboards PBIDashboards = new PowerBIDashboards();

	//Get dashboards response from request.GetResponse()
	using (var response = request.GetResponse() as System.Net.HttpWebResponse)
	{
		//Get reader from response stream
		using (var reader = new System.IO.StreamReader(response.GetResponseStream()))
		{
			responseContent = reader.ReadToEnd();

			//Deserialize JSON string
			PBIDashboards = JsonConvert.DeserializeObject<PowerBIDashboards>(responseContent);
		}
	}

	ViewData.Put<PowerBIDashboards>("PBIDashboards", PBIDashboards);
	TempData["AccessToken"] = accessToken;

	return View();
}
	

Leave a Reply

Your email address will not be published. Required fields are marked *