Shared auth in Azure Static Web Apps using only .NET (Blazor and C# functions)

Manuel Pinto
11 min readOct 26, 2023

Azure static web apps (ASWA) is becoming an interesting option to host full stack web apps due to the out-of-the-box provision of functions, DB APIs, integrated authentication provider and cheap running costs.

Although the platform is flexible enough to integrate multiple front and backend technologies, it is very convenient to use the same language for full stack development so that logic and models can be shared.

A full .NET environment is a popular choice in the Azure ecosystem and regarding authorization and authentication concerns, the framework provides useful attributes to make code cleaner and ease development. Because ASWA uses specific authentication logic and models, there are a few tweaks that we need to do in order to abstract the authentication logic so that we can use the application with the attributes and shared authentication state that .NET provides.

This article aims to show, with a .NET example; common web app auth scenarios, how to seamlessly integrate the ASWA provider into your Blazor and API code, its faults and how to better secure your application. The code supporting this article is available in github.

Azure Static Web Apps Authentication

Azure Static Web Apps — Structure — Authentication and Functions
  • The ./auth endpoints contain the authentication REST interfaces for the static web app to interact with.
  • The backend functions are available through the /api endpoints
  • There is an access control layer that can restrict the access of both auth and api endpoints and its configuration is part of the solution file staticwebapp.config.json.
  • The access restrictions are based on roles assigned to routes.
  • The static app can access the authenticated user details via the ./auth/me endpoint.
  • The Apis can access the authenticated user details by parsing the x-ms-client-principal header.

Azure Static Web Apps — Blazor Authentication and Authorization

.NET provides useful built-in authentication services that make it much easier to access auth state and wrap our components with <Authorize> attributes or access the user claims via AuthenticationState.

The underlying service definition that provides this functionality is in the AuthenticationStateProvider abstract class. We need to create an ASWA implementation that is able to parse the required User identity from the auth endpoints that ASWA provides.

Azure Static Web Apps — Blazor Authentication and Authorization

The way to interact with the Azure static web app authentication module is to make a http request to the .auth endpoints.

  • Login using the .auth/login endpoint to login via a provider, currently only MS Entra ID and Github is provided with the free account tier (X, former Twitter, was recently dropped). For custom authentication, the standard tier is required.
  • Get the logged-in user details via the .auth/me endpoint. This returns a JSON with the following properties:
{
"clientPrincipal": {
"userId": "406649392009f0014267af1ec5789ab6",
"userRoles": [
"anonymous",
"authenticated"
],
"claims": [],
"identityProvider": "github",
"userDetails": "manustatic"
}
}
  • Logout of the current provider with .auth/logout endpoint. After logging out, .auth/me simply returns: {“clientPrincipal”:null}

Create a custom AuthenticationStateProvider

The strategy to create our custom provider is to parse the JSON from the .auth/me endpoint into a valid AuthenticationState that represents the user state.
Method to override, and sample implementation:

public abstract Task<AuthenticationState> GetAuthenticationStateAsync();
public class UserAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _client;

public UserAuthenticationStateProvider(IWebAssemblyHostEnvironment environment)
{
_client = new HttpClient { BaseAddress = new Uri(environment.BaseAddress) };
}

public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try {

var state = await _client.GetFromJsonAsync<UserAuthenticationState>("/.auth/me");

var principal = state.ClientPrincipal;
principal.UserRoles = principal.UserRoles.Except(new string[] { "anonymous" }, StringComparer.CurrentCultureIgnoreCase);

if (!principal.UserRoles.Any())
{
return new AuthenticationState(new ClaimsPrincipal());
}

var identity = new ClaimsIdentity(principal.IdentityProvider);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, principal.UserId));
identity.AddClaim(new Claim(ClaimTypes.Name, principal.UserDetails));
identity.AddClaims(principal.UserRoles.Select(r => new Claim(ClaimTypes.Role, r)));

return new AuthenticationState(new ClaimsPrincipal(identity));
} catch {
return new AuthenticationState(new ClaimsPrincipal());
}
}
}
public class ClientPrincipal
{
public string? IdentityProvider { get; set; }
public string? UserId { get; set; }
public string? UserDetails { get; set; }
public IEnumerable<string>? UserRoles { get; set; }
}

public class UserAuthenticationState
{
public ClientPrincipal ClientPrincipal { get; set; }
}

This code makes a call to the .auth/me endpoint, tries to parse it to a client principal and adds the auth properties into claims of a main user identity object used to create the AuthenticationState. Proper exception handling with logging was omitted for simplicity.

❕ When we get to extract the user principal in the API side, we will see how to can take advantage of sharing a common front-backend language to reuse most of this code.

Add .NET Core dependencies

In Program.cs, add AuthorizationCore and the newly created StateProvider.

var builder = WebAssemblyHostBuilder.CreateDefault(args);
//(...)

//new code
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, UserAuthenticationStateProvider>();
//new code

await builder.Build().RunAsync();

_Imports.razor

@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization

Configure Authorizing/NotAuthorized messages

We can set a default message for when the app is loading the AuthenticationState and, at route level, display a message if the user is not authorized.

In App.razor, wrap the Router component with CascadingAuthenticationState and personalize the message you want to be shown in the different session states.

<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<Authorizing>
<p>Loading session state...</p>
</Authorizing>
<NotAuthorized>
<h1>Sorry</h1>
<p>You are not authorized to view this page</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>

Example — Protect call to backend API

Let us set up an example of a call to function API and secure it on the UI side to prevent unauthenticated users to call the protected endpoint.

Blazor UI Authentication and Authorization protection

First, let's see the Authorization attributes in action. When adding the following code to your razor page, unauthenticated users will be prompted to log in. The login provider is hardcoded to github for simplicity.

<AuthorizeView>
<Authorized>
<h2>Hello, @context.User?.Identity?.Name! <a href="/.auth/logout">Log out</a></h2>
</Authorized>
<NotAuthorized>
<h3>Please <a href="/.auth/login/github">login</a> to enable the buttons</h3>
</NotAuthorized>
</AuthorizeView>

Added three buttons that trigger different API calls, as shown in the previous diagram:

  • api/hello: Public function
  • api/hello/protected: Protected function, should be disabled for unauthenticated users.
  • api/hello/protected/admin: Protected function, should be disabled for unauthenticated user or authenticated without the ‘admin’ role.

All buttons display the message returned from the function.

<button @onclick="CallPublicFunction">
Call Public Function
</button>
<span style="font-weight: bold; color: green;">@_publicApiResponse</span>
<p>Anonymous access</p>

<button @onclick="CallProtectedFunction" disabled="@(!_isUserAuthenticated)">
Call Protected Function
</button>
<span style="font-weight: bold; color: green;">@_protectedApiResponse</span>
<p>User needs to be authenticated</p>

<button @onclick="CallProtectedAdminFunction" disabled="@(!_isUserAdmin)">
Call Protected Admin Function
</button>
<span style="font-weight: bold; color: green;">@_protectedAdminApiResponse</span>
<p>User needs to be authenticated and have 'Admin' role</p>

I’m using a boolean variable to assign the value of the disabled property of the button (_isUserAuthenticated and _isUserAdmin) in order to show a different way to get the user state through the AuthenticatedStateProvider.

private ClaimsPrincipal? User;
private bool _isUserAuthenticated = false;
private bool _isUserAdmin = false;

protected override async Task OnInitializedAsync()
{
var authState = await AuthenticationStateProvider
.GetAuthenticationStateAsync();
User = authState?.User;
_isUserAuthenticated = User?.Identity?.IsAuthenticated ?? false;
_isUserAdmin = User?.IsInRole("admin") ?? false;
}

AuthenticationStateProvider exposes the method GetAuthenticationStateAsync that enables us to programmatically have access to the logged in user state.

The functions are only returning an HTTP-OK string with ‘Hello from <Function Type> function’ that we parse into the variable to be displayed.

private async Task CallPublicFunction()
{
_publicApiResponse = await Http.GetStringAsync("/api/hello");
}

The result of this code is:

Unauthenticated UI view — Protection around buttons

Since we are not authenticated, only the public access button is enabled, and we see the message that we have defined under the <NotAuthorized> component.

Once we log in, we are able to see and click on the protected button but not the admin protected one, as we don’t have that role assigned. The default role ASWA assigns to you once logged in is ‘authenticated’. Please see MS documentation on how to add roles to users via invitation.

Authenticated UI view — Role based authentication

Securing backend calls in Static Web Apps Trust but verify!

It might seem that this app is secure enough that the user cannot call protected endpoints from the UI unless it has the specific role. The problem with static web apps is that we have to keep in mind that the app is running in the browser and the front end and backend are segregated, so it is possible to call the endpoints either by dev mistake or intentionally.

Unsecured endpoint in static web apps poses a security threat.

The backend needs to be aware of the current logged in state in order to allow or block access. In this scenario we usually would have to make the backend communicate with the authentication provider to verify the state which usually entails additional non-trivial work.

Thankfully, no additional work is required when using ASWA as the authentication layer is integrated with the proxy, and we can just instruct, via configuration, what routes are allowed for each role!

The proxy configuration can be found in the solution file staticwebapp.config.json (MS documentation on proxy config) and securing a route is as easy as adding the following JSON block:

"routes": [
{
"route": "/api/hello/protected",
"allowedRoles": [ "authenticated" ]
}
]

The proxy is now instructed to return HTTP-401 unauthorized to every call to the /api/hello/protected that does not contain the authenticated role.

Trying the previous scenario, the malicious user is now unable to directly call the endpoint as he is greeted with a 401-Unauthorized.

The logged-in user, on the other hand, is unaffected by this change. The only difference is that its role is now being compared both in the UI and backend.

Parse user Authentication State in the backend

In the previous example, we saw that the proxy returns a default HTML formatted page when the user tries to access a function without the required permissions. That is not very welcoming for the user to understand why he doesn't have access to the function. In other scenarios, it's important for the function to know which user is calling, so it can for example store an action against the user in a database.

Again, ASWA makes this super easy as the proxy has built in functionality to parse the user principal to the function via a request header. The logic for parsing the user authentication state is very similar to the JSON based Blazor logic, so there is the opportunity to share models and logic between front and backend.

Let's set up an example with the protected function /protected/admin currently requiring the admin role to be executed.

  • Remove admin role protection from the UI button. A non-admin user, but authenticated, can now call the function via the button.
  • Remove admin role access control from proxy, this function will be triggered for any call regardless of the auth state.
  • In the function, inspect the user authentication state from the provided auth header and reason on the role based access.
  • If user doesn't have the admin role, return HTTP-401 Unauthorized with a personalized (parse the username) meaningful message, explaining why access is denied.
  • Display message in the UI in RED when the response is unauthorized.

To parse the user auth state from the header ‘x-ms-client-principal’, we can use the following code:

public static ClaimsPrincipal Parse(HttpRequest req)
{
var principal = 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);
principal = JsonSerializer.Deserialize<ClientPrincipal>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}

//Common auth code used on the Blazor side to
//get the Claims principal from ClientPrincipal
principal.UserRoles = principal.UserRoles?.Except(new string[] { "anonymous" }, StringComparer.CurrentCultureIgnoreCase);

if (!principal.UserRoles?.Any() ?? true)
{
return new ClaimsPrincipal();
}

var identity = new ClaimsIdentity(principal.IdentityProvider);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, principal.UserId));
identity.AddClaim(new Claim(ClaimTypes.Name, principal.UserDetails));
identity.AddClaims(principal.UserRoles.Select(r => new Claim(ClaimTypes.Role, r)));

return new ClaimsPrincipal(identity);
}

The parsing logic from client principal to claims principal used to create the AuthenticationState as well as the ClientPrincipal model is the same as the one used in Blazor auth. To reuse the code, we will move this logic to a shared project.

Shared Models and Logic in a Full Stack .NET ASWA

Shared helper function

public static class AuthenticationHelper
{
public static ClaimsPrincipal GetClaimsPrincipalFromClientPrincipal(ClientPrincipal clientPrincipal)
{
if (clientPrincipal is null)
{
return new ClaimsPrincipal();
}

try
{
clientPrincipal.UserRoles = clientPrincipal.UserRoles.Except(new string[] { "anonymous" }, StringComparer.CurrentCultureIgnoreCase);

if (!clientPrincipal.UserRoles.Any())
{
return new ClaimsPrincipal();
}

var identity = new ClaimsIdentity(clientPrincipal.IdentityProvider);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, clientPrincipal.UserId));
identity.AddClaim(new Claim(ClaimTypes.Name, clientPrincipal.UserDetails));
identity.AddClaims(clientPrincipal.UserRoles.Select(r => new Claim(ClaimTypes.Role, r)));

return new ClaimsPrincipal(identity);
}
catch
{
return new ClaimsPrincipal();
}
}
}

Getting the auth state in the function is now as easy as:

public static class StaticWebAppsApiAuth
{
private const string ClientPrincipalHeader = "x-ms-client-principal";

public static ClaimsPrincipal Parse(HttpRequestData req)
{
var clientPrincipal = new ClientPrincipal();

if (req.Headers.TryGetValues(ClientPrincipalHeader, out IEnumerable<string> headers))
{
var header = headers?.FirstOrDefault();
var decoded = Convert.FromBase64String(header);
var json = Encoding.UTF8.GetString(decoded);
clientPrincipal = JsonSerializer.Deserialize<ClientPrincipal>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}

return AuthenticationHelper.GetClaimsPrincipalFromClientPrincipal(clientPrincipal);
}
}

Protected Admin function inspecting the user role. For non admin user, the logged in username is parsed to the return HTTP-401 Unauthorized message.

[Function("hello/protected/admin")]
public HttpResponseData RunProtectedAdminHello([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req)
{
var user = StaticWebAppsApiAuth.Parse(req);

if (!user.IsInRole("admin"))
{
var unauthResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
var unauthMessage = $"Hi {user.Identity.Name}. You are not authorized to access this function." +
"The 'admin' role is required, please contact the administrator.";
unauthResponse.WriteStringAsync(unauthMessage);
return unauthResponse;
}

var response = req.CreateResponse(HttpStatusCode.OK);
response.WriteStringAsync($"Hello '{user.Identity.Name}' from ADMIN PROTECTED function");

return response;
}

Let's run the latest changes

The network call outcome is the same as the previous example, but we were now able to parse the username and roles from within the function!

⚠️ In terms of costs and protection against attacks, having the proxy to do the access control is always preferable, as any call to the function, successful or not, will count towards your billing. This example was just for illustration purposes, the UI should have the responsibility to show the user all it needs to understand the context but if it fails to do so we should have that extra security layer.

Summary

Hope you now have a better understanding of how authentication works within ASWA, and how to better take advantage of the built-in tools making development of secure web apps in .NET so much easier. It is really a game changer when looking for affordable hosting options without having to compromise on security.

All the code from these examples can be found in the github repo.

--

--