Authentication and Authorization
Part 3 of 3: Azure Static Web Apps – Blazer WebAssembly Front End and C# Azure Functions Back End – Authentication and Authorization
Azure Static Web Apps is a very flexible and powerful tool on Azure. You can mix and match different frontend frameworks with many different backend frameworks. This series will focus on using C# for both the Frontend and Backend.
Prerequisites:
-
-
- A GitHub Account
- An Azure Account
- Visual Studio 2022 (Community Edition will be just fine)
- Nodejs and NPM installed
-
Part 1 of 3: Setting Up Local Development Environment, Setting Up Client and API Communications, Publishing to GitHub and Azure
Part 2 of 3: Understanding Preview Environments
Part 3 of 3: Authentication and Authorization
Go Directly To The Code: Github
Part 3 – Authentication and Authorization
According to Microsoft: “Azure Static Web Apps provides a streamlined authentication experience. By default, you have access to a series of pre-configured providers, or the option to register a custom provider… The preconfigured Azure Active Directory provider allows any Microsoft Account to sign in. To restrict login to a specific Active Directory tenant, configure a custom Azure Active Directory provider. ” (https://docs.microsoft.com/en-us/azure/static-web-apps/authentication-authorization). Furthermore, they explain that if you want to do a custom provider you will need to upgrade your plan from the free version to the Standard Plan. (https://docs.microsoft.com/en-us/azure/static-web-apps/authentication-custom?tabs=aad). For now, we will focus on the free AAD preconfigured tenant, and using the authentication in our local development environment.
Important Auth URLs
When the application is deployed in Azure, or the Azure Static Web Apps CLI is running, there are some system routes that have been created. The ones that we will be focusing on are
- /.auth/login/aad
- /.auth/me
- /.auth/logout
We will also be discussing the importance of the following HTTP header that is added when accessing HTTP Triger Functions:
- x-ms-client-principal
Add a Custom Authentication State Provider to the Client Application
As of this writing the steps necessary to have your Blazor app know what to do with that the /.auth/me end point are not specified so we will be combining the information available on these three documents to come up with our solution:
The first thing that we should do is:
-
1 – create a shared class library that both the client and server will use. We will also want to develop this on its own branch.
2 – Go to the git changes tab and pull all of the latest changes and delete your named_branches branch if it is still there.
3 – Create a new branch based off of master called “add_authentication”.
4 – Switch to the solution explorer and switch to the solution view instead of the folder view if you are still in the folder view.
5 – Do a right-click on the Solution and select Add -> New Project
6 – Select the C# project that targets .NET or .NET Standard
-
7 – Call the project name “Shared”
-
8 – Click on Next and Create
9 – When the Shared project appears delete the Class1.cs file
10 – Create a new folder called “Models” within the Shared project.
11 – Create a new file class in the Models folder called ClientPrincipal.cs.
Give it the following code:
using System.Security.Claims;
namespace Shared.Models
{
public class ClientPrincipal
{
public string IdentityProvider { get; set; } = null!;
public string UserId { get; set; } = null!;
public string UserDetails { get; set; } = null!;
public IEnumerable<string> UserRoles { get; set; } = null!;
public IEnumerable<ClientPrincipalClaim>? Claims { get; set; } = null!;
}
public class ClientPrincipalClaim
{
public string Typ { get; set; } = null!;
public string Val { get; set; } = null!;
}
}
-
12 – Create a new folder called “Extensions”
13 – Create a new file in the Extensions folder called “ClientPrincipalExtensions.cs”
Give it the following code:
using Shared.Models;
using System.Security.Claims;
namespace Shared.Extensions
{
public static class ClientPrincipalExtensions
{
public static ClaimsIdentity ToClaimsIdentity(this ClientPrincipal clientPrincipal)
{
var claimsIdentity = new ClaimsIdentity(clientPrincipal.IdentityProvider);
claimsIdentity = new ClaimsIdentity(clientPrincipal.IdentityProvider);
claimsIdentity.AddClaim(new Claim(ClaimTypes.Name, clientPrincipal.UserDetails));
claimsIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, clientPrincipal.UserId));
claimsIdentity.AddClaims(clientPrincipal.UserRoles.Select(r => new Claim(ClaimTypes.Role, r)));
if(clientPrincipal.Claims is not null)
{
foreach (var claim in clientPrincipal.Claims)
{
claimsIdentity.AddClaim(new Claim(claim.Typ, claim.Val));
}
}
return claimsIdentity;
}
}
}
To the Client project — go to Dependencies and add a Project Dependency and select the Shared class library.
Add the “Microsoft.AspNetCore.Components.Authorization” package.
To the root of the Client project add a new class called “CustomerAuthenticationStateProvider.cs” and give it the following code:
using Microsoft.AspNetCore.Components.Authorization;
using Shared.Extensions;
using Shared.Models;
using System.Net.Http.Json;
using System.Security.Claims;
namespace Client
{
public class CustomerAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _httpClient;
public CustomerAuthenticationStateProvider(HttpClient httpClient)
{
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var claimsIdentity = new ClaimsIdentity();
var response = await _httpClient.GetFromJsonAsync<ClientPrincipalPayLoad>("/.auth/me");
if (response is not null)
{
if (response.ClientPrincipal is not null)
{
claimsIdentity = response.ClientPrincipal.ToClaimsIdentity();
}
}
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
var authenticationState = new AuthenticationState(claimsPrincipal);
return authenticationState;
}
private class ClientPrincipalPayLoad
{
public ClientPrincipal? ClientPrincipal { get; set; }
}
}
}
To begin we are inheriting from the AuthenticationStateProvider class. Inheriting from that forces us to override a GetAuthenticationStateAsync method. In order for us to fulfill the requirements of this method we are going to make an http request to ‘/.auth/me’ as directed by the Microsoft documentation. The response from that end point is a “ClientPrincipal” which is not the same as a “ClaimsPrincipal”. However, in order for us to provide a valid authentication state we have to give the authentication state a ClaimsPrincipal. So, what we are doing is converting the ClientPrincipal into a Claims Principal. We start off with an empty ClaimsIdentity. If we get a response back from the http end point and that response is not null, then we add claims to that claims identity (using the extension method that we created earlier in the shared project). We then turn the ClaimsIdentity in to a ClaimsPrincipal and then turn that ClaimsPrincipal into an Authentication state and return that authentication state. The private method for ClientPrincipalPayLoad is there to store the HttpResponse from /.auth/me and has one property of ClientPrincipal that may or may not have a ClientPrincipal model in the response.
Now that we have a custom authentication state provider, we need to register it.
Modify your Program.cs file in the Client project to look like this:
using Client;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddOptions();
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, CustomerAuthenticationStateProvider>();
await builder.Build().RunAsync();
Modify Client Views
Now that our application knows to get it’s authentication state by query ‘/.auth/me’, we can now have our application handle the view depending on whether the user is logged in or not.
To the _Imports.razor file in the client project add the following usings at the end of the file:
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
Give it the following code
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation
<AuthorizeView>
<Authorized>
Hello, @context.User.Identity?.Name!
<button class="nav-link btn btn-link" @onclick="BeginSignOut">Log out</button>
</Authorized>
<NotAuthorized>
<a href="/.auth/login/aad">Log in</a>
</NotAuthorized>
</AuthorizeView>
@code{
private void BeginSignOut(MouseEventArgs args)
{
Navigation.NavigateTo("/.auth/logout", true);
}
}
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4 auth">
<LoginDisplay />
<a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
To the Apop.razor file (in the root of the Client project).
Modify the code as follows:
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated != true)
{
<p>Please Login To Use This Resource</p>
}
else
{
<p role="alert">You are not authorized to access this resource.</p>
}
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
The most important change here is the App.razor change that we made. We introduce a new CascadingAuthenticationState component which will use our newly injected CustomAuthenticationState class and let all of our components know the current user state. We can then access the current user state within an AuthorizeView component, and in the Authorized subcomponent we can access the context and the user information as we did on the LoginDisplay component.
But that is not all. We can also know the authentication state in code, and not just the components by injecting the authentication state into the component code and using the injected object.
<b>Update the Index.razor page as follows for an example of this.</b>
@page "/"
@using System.Security.Claims
@using Microsoft.AspNetCore.Components.Authorization
@inject AuthenticationStateProvider _authenticationStateProvider;
<PageTitle>Index</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<p>@authMessage</p>
@if(claims.Count() > 0) {
<dl>
<dt>Auth Provider</dt>
<dd>@authProvider</dd>
<dt>Claims</dt>
<dd>
<ul>
@foreach(var claim in claims)
{
<li>@claim.Type: @claim.Value</li>
}
</ul>
</dd>
</dl>
}
@code {
private string? authMessage;
private string? authProvider;
private IEnumerable<Claim> claims = Enumerable.Empty<Claim>();
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if(user.Identity is not null && user.Identity.IsAuthenticated)
{
authMessage = $"{user.Identity.Name} is authenticated.";
claims = user.Claims;
}
else
{
authMessage = "The user is NOT authentifcated.";
}
}
}
Run the application in development
Ensure that you have your terminal running the npm start and waiting for the client and server to start up and then press F5 in Visual Studio or press the Green play button to start the application up in development. When you first come to the index page you will see that it says that you are not logged in, and also a login button is available in the top right corner.
You will also notice that a request to /.auth/me was made as well.
For now go ahead and click on the Log In button. When you are at that page it will allow you to specify a username or email address and give any additional claims that you like.
After you are logged in the page will say that you are logged in and list your claims.
Stop the debuggers.
Use Authentication State with Azure Functions HTTP Triggers
Our goal in this example is to create an endpoint that will give the same details as /.auth/me to our own /api/me route to prove that we can get a claims principal from an x-ms-client-principal HTTP Request Header.
Open the Api project from the solution explorer. Do a Right-Click on the “Dependencies” node and select “Add Project Reference”.
Add a Project reference to the Shared project.
Give it the following code
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using Shared.Models;
using System;
using System.Text;
using System.Text.Json;
namespace Api
{
public static class Me
{
[FunctionName("Me")]
public static IActionResult Run(
[HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
ILogger log)
{
var myResponse = new ClientPrincipalResponse();
var myClientPrincipal = GetClientPrincipal(req, log);
if (myClientPrincipal.UserId is not null)
{
myResponse.ClientPrincipal = myClientPrincipal;
}
return new OkObjectResult(myResponse);
}
private static ClientPrincipal GetClientPrincipal(HttpRequest req, ILogger log)
{
var clientPrincipal = new ClientPrincipal();
if (req.Headers.TryGetValue("x-ms-client-principal", out var header))
{
var data = header[0];
var decoded = Convert.FromBase64String(data);
var json = Encoding.UTF8.GetString(decoded);
clientPrincipal = JsonSerializer.Deserialize<ClientPrincipal>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
return clientPrincipal;
}
private class ClientPrincipalResponse
{
public ClientPrincipal ClientPrincipal { get; set; } = null;
}
}
}
To the pages folder of the client add a new file called Me.razor
Give it the following code
@page "/me"
@inject HttpClient _httpClient;
<PageTitle>Me</PageTitle>
<h1>Me</h1>
<pre>@responseText</pre>
@code {
private string? responseText { get; set; }
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
var response = await _httpClient.GetStringAsync("/api/me");
responseText = response;
}
}
Please note that as of this reading the claims are actually missing from x-ms-client-principal. If you google search that situation, you will see that there are plenty of other people with that same situation and no work around is available (as of the time of this writing). However since there is at least the unique identifier for the user and their roles there are still many security policies that you can do. Unfortunately, claims-based ones, within the API is not possible. Claims based ones within the client are. Despite the fact that the documentation says that /.auth/me and x-ms-client-principal should contain the same information, they do not at this time, but close enough. Hopefully Microsoft will fix this in future releases of this product.
Commit and Merge Changes
Ensure that all of the things work locally and then commit the changes using the Git Changes tab of Visual Studio. Push the changes, then create a pull request. Make the target of the pull request in this instance to the Dev branch instead of the Master branch. This way we are sure that we are only deploying to the Dev environment for now.
Confirm the build and deploy works to the development slot.
Once the deploy is complete browse to the development environment by using the link in the Static Web Apps Resource, and the Environments tab from the Azure Portal.
Practice logging in and out and comparing the results of the Index page and the Me page.
If things work out the way that you would like them to. Merge the changes from Dev to Staging and from Dev to Production testing everything along the way.
Conclusion
Azure Static Web Apps is an exciting powerful technology that allows you to focus on code, rather than infrastructure. This is by far the easiest way to have the client and api in a mono repo with a code build, deploy set up with the fewest number of Azure resources that you need to manage.
About Intertech
Intertech is a Software Development Consulting Firm that provides single and multiple turnkey software development teams, available on your schedule and configured to achieve success as defined by your requirements independently or in co-development with your team. Intertech teams combine proven full-stack, DevOps, Agile-experienced lead consultants with Delivery Management, User Experience, Software Development, and QA experts in Business Process Automation (BPA), Microservices, Client- and Server-Side Web Frameworks of multiple technologies, Custom Portal and Dashboard development, Cloud Integration and Migration (Azure and AWS), and so much more. Each Intertech employee leads with the soft skills necessary to explain complex concepts to stakeholders and team members alike and makes your business more efficient, your data more valuable, and your team better. In addition, Intertech is a trusted partner of more than 4000 satisfied customers and has a 99.70% “would recommend” rating.
Part 1 of 3: Setting Up Local Development Environment, Setting Up Client and API Communications, Publishing to GitHub and Azure
Part 2 of 3: Understanding Preview Environments
Part 3 of 3: Authentication and Authorization
info@intertech.com
651. 288. 7000
Consulting Services
Education Services
About Us
Careers
Intertech, Inc. All Rights Reserved 2024
We Use Cookies From Time to Time
By continuing to use this website you consent to their use.
Intertech, Inc. – 1575 Thomas Center Drive – Eagan, MN 55122 — Operation Rubber Duck