An Open Hate Letter to Microsoft
Forward
Isn’t it funny that I bill myself as a python developer and my first post is about C#? I think it’s downright hilarious. I may transition later into using more C, C++, or Rust. Functional programming is much more elegant to me than the object oriented clusterf*ck that is see sharp.
For ease of understanding, whenever referencing a client id, GUID cccccccc-cccc-cccc-cccc-cccccccccccc will be used; whenever referencing a tenant id, GUID dddddddd-dddd-dddd-dddd-dddddddddddd will be used. You might see other GUIDs but they don’t matter.
By the way, do you want to skip all the ranting? Out of sheer rage, I have rehashed the tutorial to include the development of your own APIs, some of the quirks of Azure AD, and MUCH better documented details about the actual process.
Problem
Recently at work, I was tasked with a quick 5 minute task of publishing a development project to an Azure App Service. This project was a Blazor Wasm (with Mudblazor and a handful of CRUD API endpoints… without Swagger for whatever reason). The app registration already existed, the app service already existed, and the project was working just fine in development. I went into Azure and downloaded the publish profile, threw it into VS, signed in about 5 times because of MFA and… 401. For the next week straight, I would check the network tab of Firefox’s debugger and read 401. 401. 401. For one reason or another, in production, the client could not authenticate properly to the server. The issuer was bad, the token was invalid, the scope was outright missing. Tweak after tweak, I got closer to authenticating, in a sick game of 2 steps forwards, 1 step back. At one point, even the development environment was unable to authenticate with Azure.
At least I learned a lot about JWT tokens over this week. Did you know that they’re pronounced “jawt” and not “jay double-u tee”? Did you know that saying “JWT token” is like saying chai tea? Did you notice that I’m going to continue saying JWT token because I don’t have any self respect?
Token?
Here’s an example of a decoded v1.0 token
Here’s an example of a decoded v2.0 token.
A JWT token is two JSON objects and a signature. The first is a header, which describes a few things about the token, such as the algorithm used for the signature. The second is a variety of “claims”. Commonly the claims "aud"
, "iss"
, "tid"
(Audience, Issuer, Tenant) are present in the tokens recieved from Microsoft. The final section is simply a signature against the rest of the token.
Microsoft really loves JWT tokens. All of the tokens I worked with were JWT, even if they were seemingly mangled at times. Microsoft will issue JWT tokens for it’s own purposes and for yours. If you’re ever curious about what kind of token you have on your hands, just try to decode it! If the signature is jacked up or if you see a "nonce"
in there, assume it’s from Microsoft. If this token ends up in the header of a request to your own API, you have something seriously messed up.
The token you will work with 99% of the time is the access token. It’s received after MSAL.js makes an HTTP POST to your tenant’s token endpoint with Microsoft1.
Go With The Flow
The authentication flow of our app was this:
- User visits SPA, and upon attempting to access a protected resource (e.g. an api endpoint or a page with an
[Authorize]
2 tag), is thrown to/authentication/login
. - MSAL.js takes over, signing in the user with Microsoft to their personal/domain account. Microsoft’s identity platform gives the SPA an authorization code.
- The user is sent back to the original requested resource.
- MSAL.js makes an HTTP POST to Microsoft’s token provider with the authorization code. In the case of using a v2.0 token, the endpoint is https://login.microsoftonline.com/b16b00b5-0000-0000-0000-000000000000/oath2/v2.0/token.
- MSAL.js receives several tokens in the response header:
**access_token**
,refresh_token
,id_token
, andclient_info
. These are saved in the browser’s storage. - The request to the resource is then made, with the
access_token
riding along in the header. It’ll look likeBearer eyJ0eXAiOiJKV1...
- The token’s signature and issue/expire date is validated. If the scope claim (
scp
) matches the required scope for an endpoint, it returns the resource.
The Actual Tutorial
The use-case we’re gearing towards here is a Blazor Wasm project. Upon creation of a new solution, there’s a Server
, Client
, and Shared
directory. The Client
project is compiled to Wasm and served to users of your web app. The Server
serves the Wasm as well as any controllers configured. The default Weather forecast API will be made protected, available only to users who have authenticated with the appropriate scope.
As a side note- Azure is being rebanded to Entra or something like that. Nothing really changes except another pipe bomb3 delivered to Bill Gates’ 59th vacation home.
App Registration
Search for or navigate to App Registrations. From there, create a new registration for your new shiny app. Choose a name, and then depending on the intended audience of your app, select the supported account types. If you’re building an internal project for work, you’re probably going to go with single tenant. If you’re building the next Uber but only for sober people, go with personal or multitenant- or both! This can be changed later but beware that it will almost certainly create project-ending errors down the line that require you to create an entirely new solution and/or registration. If this happens to you, email me your address so I can mail you your embossed L.
Leave the Redirect URI blank; we’ll configure that in the next step.
Configure Authentication
Users obviously need to be able to login to your new webapp. Under Authentication, + Add a platform, select Single-page application.
Configure the Redirect URI as https://localhost:1234/authentication/login-callback
(substituting the port for the port of your development site, which you’ll find either in MySolution/Client/Properties/launchSettings.json
or dotnet run
). You can either repeat this step for your production site or, for ease of configuration, tack on your production site as another valid Redirect URI.
Select both Access tokens and ID tokens since both will be required for a SPA.
Fix the manifest
Big numbers > small numbers > null numbers. You can take that to the bank. On the sidebar, there’s a few different menus. Really it’s just a nice fancy wrapper around the app manifest, a JSON file that represents everything that Microsoft cares about regarding your web app. Under Manifest, change the value of "accessTokenAcceptedVersion"
from null
to 2
(if it isn’t already). This ensures cohesion of token types throughout your new project.
Home > App registrations > FooBar > Manifest
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"acceptMappedClaims": null,
"accessTokenAcceptedVersion": 2,
"addIns": [],
"allowPublicClient": null,
/* snip */
}
Create The Project
This command is functionally similar to creating a new Blazor WebAssembly App in Visual Studio. However, I hate Visual Studio.
dotnet new blazorwasm -o FooBar --pwa --hosted
Dependencies
We rely on a few nuget packages to perform authentication for the API.
Server:
Client:
- Microsoft.AspNetCore.Components.Authorization
- Microsoft.AspNetCore.Components.WebAssembly.Authentication
- Microsoft.Authentication.WebAssembly.Msal
- Microsoft.Extensions.Http
App Settings
On the Overview page, copy/paste the Application (client) ID (cccccccc-cccc-cccc-cccc-cccccccccccc) and Directory (tenant) ID (dddddddd-dddd-dddd-dddd-dddddddddddd) into the appsettings.json
of your Server
and Client
project.
For the client, "Scopes"
will be the requested scopes during initial authentication. For the server, "Scopes"
is used inside the [RequiredScope]
attribute in the controller. It doesn’t necessarily need to be in the config.
File: FooBar/Client/wwwroot/appsettings.json
{
"AzureAD": {
"Authority": "https://login.microsoftonline.com/dddddddd-dddd-dddd-dddd-dddddddddddd",
"ClientId": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"ValidateAuthority": true
},
"ServerApi": {
"Scopes": "api://cccccccc-cccc-cccc-cccc-cccccccccccc/weather_forecast"
}
}
File: FooBar/Server/appsettings.json
{
/* snip */
"AzureAD": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "yourdomainhere.onmicrosoft.com",
"CallbackPath": "/authentication/login-callback",
"TenantId": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"ClientId": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"Scopes": "weather_forecast"
}
}
Setup Services
The client needs to have a few services setup. The HttpClient needs to know to attach the access token to requests made to the server, and the application needs to know the necessary scopes and to request them from Azure AD.
File: FooBar/Client/Program.cs
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using FooBar.Client;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddHttpClient("FooBar.ServerAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("FooBar.ServerAPI"))
.AddLogging(options => {
options.SetMinimumLevel(LogLevel.Trace);
options.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);;
});
builder.Services.AddMsalAuthentication(options => {
builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
options.ProviderOptions.DefaultAccessTokenScopes.Add(
builder.Configuration.GetSection("ServerApi")["Scopes"] ?? throw new InvalidOperationException("ServerApi:Scopes is missing from appsettings.json")
);
});
await builder.Build().RunAsync();
The server needs to configure how it accepts and verifies tokens received from the client.
File: FooBar/Server/Program.cs
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
// snip
var openidconfig = new ConfigurationManager<OpenIdConnectConfiguration>(
$"{builder.Configuration["AzureAD:Instance"]}{builder.Configuration["AzureAD:TenantID"]}/v2.0/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever());
var openidkeys = openidconfig.GetConfigurationAsync().Result.SigningKeys;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => {
builder.Configuration.Bind("AzureAd", options);
options.TokenValidationParameters.IssuerSigningKeys = openidkeys;
options.TokenValidationParameters.ValidIssuers = new[] {
$"{builder.Configuration["AzureAD:Instance"]}{builder.Configuration["AzureAD:TenantID"]}/v2.0",
"https://login.microsoftonline.com/370b9a62-e506-4576-b5c8-79aafb53589a/v2.0"
// Add more issuers for a multi-tenant app
};
});
builder.Services.AddAuthorization(options => {
options.AddPolicy("Bearer", new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser().Build());
});
var app = builder.Build();
// snip
app.UseAuthorization();
app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();
Add the authentication service to your index.html if it doesn’t exist already.
File: FooBar/Client/wwwroot/index.html
<body>
<!-- snip -->
<script src="_content/Microsoft.Authentication.WebAssembly.Msal/AuthenticationService.js"></script>
</body>
Add Login/Logout Functionality
Add Authentication.razor to handle authentication endpoints like /authentication/login-callback
.
File: FooBar/Client/Pages/Authentication.razor
@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="@Action" />
@code {
[Parameter] public string? Action { get; set; }
}
Optionally, you can wrap the entire page in the <CascadingAuthenticationState>
tag, allowing you to easily just use <AuthorizeView
wherever you want. If you do so, it’s suggested to add @using Microsoft.AspNetCore.Components.Authorization
to your _Imports.razor
file.
File: FooBar/Client/Shared/MainLayout.razor
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject NavigationManager NavigationManager
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
<CascadingAuthenticationState>
<AuthorizeView>
<Authorized>
<a href="authentication/logout" @onclick="Logout">Logout</a>
</Authorized>
<NotAuthorized>
<a href="/authentication/login" @onclick="Login">Login</a>
</NotAuthorized>
</AuthorizeView>
</CascadingAuthenticationState>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
@code {
protected void Logout(MouseEventArgs args) {
NavigationManager.NavigateToLogout("/authentication/logout");
}
protected void Login(MouseEventArgs args) {
NavigationManager.NavigateToLogin("/authentication/login");
}
}
Secure Web API
Adding the [Authorize]
tag requires a user to be logged in. Adding the [RequiredScope]
tag requires that scope4. Both tags can be applied to either the entire class or to each individual method.
File: FooBar/Server/WeatherForecastController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Identity.Web.Resource;
using FooBar.Shared;
namespace FooBar.Server.Controllers;
[Authorize]
[ApiController]
[Route("[controller]")]
[RequiredScope(RequiredScopesConfigurationKey = "AzureAd:Scopes")]
public class WeatherForecastController : ControllerBase
{
/* snip */
}
Add request logic
The HttpClient knows to automatically add the access token in the header of requests made to the Server because of the setup we did in FooBar/Client/Program.cs
. After doing all the heavy work, actually making and receiving requests is dead simple.
File: FooBar/Client/Pages/FetchData.razor
@page "/fetchdata"
@using FooBar.Shared
@using Microsoft.AspNetCore.Components.Authorization
@inject HttpClient Http
@inject AuthenticationStateProvider AuthenticationStateProvider
<PageTitle>Weather forecast</PageTitle>
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from the server.</p>
<CascadingAuthenticationState>
<AuthorizeView>
<Authorized>
<!-- snip -->
</Authorized>
<NotAuthorized>
<p>You're not authorized to see this page.</p>
</NotAuthorized>
</AuthorizeView>
</CascadingAuthenticationState>
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
try {
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
} catch (HttpRequestException) {
// HTTP 401 most likely- check for a bad token or missing scope
} catch (AccessTokenNotAvailableException) {
// User is not authenticated
} catch (Exception e) {
Console.WriteLine(e)
}
}
}
Begin drinking heavily
At this point, you have a proper SPA with a backend to handle any heavy lifting for you. To build this into a real product, you’d probably end up configuring a database, entityframework, role based claims, and honestly give up. At this point, give up. Remember when everyone was telling unemployed people and journalists to “learn to code”? That was a big fucking scam. Now everyone says “learn a trade”. Total rip-off.
-
Protip: You can access the token easily in Firefox after authentication by opening dev tools. Check under Storage > Session Storage. ↩
-
Assume that anything you place in FooBar.Client is completely insecure. Storing any passwords or sensitive information- even if protected with an
[Authorize]
tag, is bad practice. If you don’t want just anyone to see something, stash it server side. ↩ -
Satire. ↩
-
I shouldn’t have to type this. ↩