I have a Blazor Server app that requires users to sign in with a Microsoft account (within my company's tenant). I'm trying to further secure it by only allowing users who meet a certain criteria, but my attempt to do so renders the app completely inoperable in production.
Cause of the problem
The way I implemented authorization (full diff at the bottom of the question) was as follows:
- Created a class
DoingsAuthzMiddlewarewhich will add an identity including theDoingsAuthzclaim to validated users. - Added
builder.Services.AddAuthorization(opts => opts.AddPolicy("DoingsAuthz", apBuilder => apBuilder.RequireClaim("DoingsAuthz")));andapp.UseMiddleware<DoingsAuthzMiddleware>();to myProgram.cs. - Added
@attribute [Authorize(Policy = "DoingsAuthz")]to_Imports.razorto secure all pages by default - Added
@attribute [AllowAnonymous]to pages that should allow anonymous access (e.g. the 403 page).
I also had to make some modification to my Routes.razor to get the whole cascading authentication state thing to work, and the commit includes a couple of new views for the 403 scenario.
Symptoms
When I run locally using dotnet run or any other means, the site works just as expected. It lets in authorized users, rejects unauthorized ones, and seemingly has no other side-effects.
In production, the app is deployed on an Azure Web App using the free F1 SKU, running Windows with the dotnet v9.0 runtime stack. When I deployed this and 18 other commits, upon hitting the homepage, I was met with a HTTP 500 error.
Investigation
Back and forth
Immediately after the bad deploy, I re-deployed the last known stable commit. This worked as expected. Next, I tried again to deploy the bad version, to rule out an error with that specific deployment. I experienced the same symptoms as before. I then performed a bisect, determining the commit described above to be the offending one.
web.config
Using az webapp log tail and hitting the root page's URL, I got what seemed like a HTML page. Stripping away all the irrelevant HTML, we're left with this content:
HTTP Error 500.0 - Internal Server Error
The page cannot be displayed because an internal server error has occurred.
Most likely causes:
- IIS received the request; however, an internal error occurred during the processing of the request. The root cause of this error depends on which module handles the request and what was happening in the worker process when this error occurred.
- IIS was not able to access the web.config file for the Web site or application. This can occur if the NTFS permissions are set incorrectly.
- IIS was not able to process configuration for the Web site or application.
- The authenticated user does not have permission to use this DLL.
- The request is mapped to a managed handler but the .NET Extensibility Feature is not installed.
Things you can try:
- Ensure that the NTFS permissions for the web.config file are correct and allow access to the Web server's machine account.
- Check the event logs to see if any additional information was logged.
- Verify the permissions for the DLL.
- Install the .NET Extensibility feature if the request is mapped to a managed handler.
- Create a tracing rule to track failed requests for this HTTP status code. For more information about creating a tracing rule for failed requests, go to http://go.microsoft.com/fwlink/?LinkID=66439.
This led me down my first rabbit hole of inspecting web.config. The content of the file has not changed compared to the last deploy. The NTFS permissions on the file also have not changed. I gather that IIS often gives this error when the problem is entirely unrelated to web.config, so I started operating under the assumption that this was a red herring.
Code changes
I tried a few things with the codebase and created some subsequent deploys. I tried every suggested I could find:
- Explicitly calling
builder.WebHost.UseStaticWebAssets(); - Disabling scoped CSS
- Disabling
PublishWithAspNetCoreTargetManifest - Removing the
AuthorizeAttributefrom_Imports.razor. - Using AOT compilation.
None of these had any effect. The only way I found to prevent the 500 errors was to completely revert the commit that introduced this behaviour.
Local IIS deployment
I tried hosting the app via IIS locally. I compiled and published the code with the exact same flags I use in the CI pipeline for deployment, added configuration identical to production in appsettings.json (I know that's insecure, don't worry, I'm using environment variables in prod) and pointed IIS at the folder.
Upon hitting localhost in the browser, 3 errors appeared in the event viewer (Windows Logs/Application), all from IIS' PID (IIS AspNetCore Module V2). In chronological order, they were:
Provided application path does not exist, or isn't a .dll or .exe.Could not find 'aspnetcorev2_inprocess.dll'. Exception message:(there was no exception message)Failed to start application '/LM/W3SVC/1/ROOT', ErrorCode '0x8000ffff'.
I agree that within the published binaries there is no such file as aspnetcorev2_inprocess.dll, in any kind of build, both before and after the bad commit, though I do have several copies each under directories C:\Program Files, C:\Program Files (x86), %USERPROFILE%\.nuget\packages, and %LOCALAPPDATA%.
Final Questions
So my questions are:
- Why does IIS fail after this commit is introduced?
- How can I modify the code and/or compiler options such that it doesn't give me these errors?
Full Diff
Full dump of the commit that caused this issue.
commit a24aa59fec75eb9e4f4d2d4fa3c7bc3309494144
Author: Josh Brunton <REDACTED>
Date: Tue Oct 14 12:39:36 2025 +0100
feat: Add claims for auth
Make using the app dependent on being an activated worker in AX.
diff --git a/src/Doings.Web/Middleware/DoingsAuthzMiddleware.cs b/src/Doings.Web/Middleware/DoingsAuthzMiddleware.cs
new file mode 100644
index 0000000..3a6fa41
--- /dev/null
+++ b/src/Doings.Web/Middleware/DoingsAuthzMiddleware.cs
@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using Doings.Lib.Ax.Common.Enums;
+using Doings.Lib.Ax.Common.Services;
+
+namespace Doings.Web.Middleware;
+
+public class DoingsAuthzMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly IServiceScopeFactory _scopeFactory;
+
+ public DoingsAuthzMiddleware(RequestDelegate next, IServiceScopeFactory scopeFactory)
+ {
+ _next = next;
+ _scopeFactory = scopeFactory;
+ }
+
+ public async Task InvokeAsync(HttpContext httpContext)
+ {
+ if (!httpContext.User.Identity!.IsAuthenticated)
+ {
+ await _next(httpContext);
+ return;
+ }
+
+ using var scope = _scopeFactory.CreateScope();
+ var doingsSharedDataService = scope.ServiceProvider.GetRequiredService<IDoingsSharedDataService>();
+
+ var worker = await doingsSharedDataService.WhoAmI();
+ if (worker.IsActive is not NoYes.Yes)
+ {
+ await _next(httpContext);
+ return;
+ }
+
+ IEnumerable<Claim> claims = [new("DoingsAuthz", "DoingsAuthz")];
+ ClaimsIdentity appIdentity = new(claims);
+ httpContext.User.AddIdentity(appIdentity);
+
+ await _next(httpContext);
+ }
+}
diff --git a/src/Doings.Web/Program.cs b/src/Doings.Web/Program.cs
index 9960291..3186274 100644
--- a/src/Doings.Web/Program.cs
+++ b/src/Doings.Web/Program.cs
@@ -1,9 +1,11 @@
using CommunityToolkit.Mvvm.Messaging;
using Doings.Application.Services;
using Doings.Web.Auth;
+using Doings.Web.Middleware;
using Doings.Web.View;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.Identity.Web;
@@ -45,22 +47,28 @@ internal class Program
builder.Services.AddScoped<IMessenger, WeakReferenceMessenger>();
+ builder.Services.AddAuthorization(opts => opts.AddPolicy("DoingsAuthz", apBuilder =>
+ {
+ apBuilder.RequireClaim("DoingsAuthz");
+ }));
+
// Add services to the container.
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
bool doMock = !string.IsNullOrWhiteSpace(builder.Configuration["Doings:DoMock"]);
foreach (var s in ApplicationServiceHelper.GetInternallyImplementedServices(doMock))
{
builder.Services.Add(s);
}
return builder;
}
private static WebApplication CreateApp(WebApplicationBuilder builder)
{
var app = builder.Build();
+ app.UseMiddleware<DoingsAuthzMiddleware>();
app.UseAuthorization();
app.UseExceptionHandler(new ExceptionHandlerOptions
{
diff --git a/src/Doings.Web/View/Components/RedirectComponent.cs b/src/Doings.Web/View/Components/RedirectComponent.cs
new file mode 100644
index 0000000..24d6ad4
--- /dev/null
+++ b/src/Doings.Web/View/Components/RedirectComponent.cs
@@ -0,0 +1,21 @@
+using Microsoft.AspNetCore.Components;
+
+namespace Doings.Web.View.Components;
+
+public class RedirectComponent : ComponentBase
+{
+ private readonly NavigationManager _navMgr;
+
+ [Parameter] public string Uri { get; set; } = string.Empty;
+
+ public RedirectComponent(NavigationManager navMgr)
+ {
+ _navMgr = navMgr;
+ }
+
+ protected override void OnInitialized()
+ {
+ _navMgr.NavigateTo(Uri);
+ base.OnInitialized();
+ }
+}
diff --git a/src/Doings.Web/View/Layout/MinimalLayout.razor b/src/Doings.Web/View/Layout/MinimalLayout.razor
new file mode 100644
index 0000000..f6816d7
--- /dev/null
+++ b/src/Doings.Web/View/Layout/MinimalLayout.razor
@@ -0,0 +1,30 @@
+@inherits LayoutComponentBase
+
+<div class="page">
+ <main>
+
+ <FluentMainLayout NavMenuTitle="Navigation menu" NavMenuWidth="300">
+ <Header>
+ <img src="/img/logo-orange.png" alt="Company logo" style="height: 2em; margin-right: .5em;" />
+ <a href="/" style="color: white; text-decoration: none; font-size: 1.8em;">
+ Doings
+ </a>
+ </Header>
+ <SubHeader></SubHeader>
+ <Body>
+ <article class="content px-4">
+ @Body
+ </article>
+ </Body>
+ </FluentMainLayout>
+
+ <FluentToastProvider />
+ <FluentDialogProvider />
+ <FluentTooltipProvider />
+ <FluentMessageBarProvider />
+ <FluentMenuProvider />
+
+ <DialogReceiver />
+ <ToastReceiver />
+ </main>
+</div>
diff --git a/src/Doings.Web/View/Layout/MinimalLayout.razor.css b/src/Doings.Web/View/Layout/MinimalLayout.razor.css
new file mode 100644
index 0000000..38d1f25
--- /dev/null
+++ b/src/Doings.Web/View/Layout/MinimalLayout.razor.css
@@ -0,0 +1,98 @@
+.page {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+}
+
+main {
+ flex: 1;
+}
+
+.sidebar {
+ background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
+}
+
+.top-row {
+ background-color: #f7f7f7;
+ border-bottom: 1px solid #d6d5d5;
+ justify-content: flex-end;
+ height: 3.5rem;
+ display: flex;
+ align-items: center;
+}
+
+ .top-row ::deep a, .top-row ::deep .btn-link {
+ white-space: nowrap;
+ margin-left: 1.5rem;
+ text-decoration: none;
+ }
+
+ .top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
+ text-decoration: underline;
+ }
+
+ .top-row ::deep a:first-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+@media (max-width: 640.98px) {
+ .top-row {
+ justify-content: space-between;
+ }
+
+ .top-row ::deep a, .top-row ::deep .btn-link {
+ margin-left: 0;
+ }
+}
+
+@media (min-width: 641px) {
+ .page {
+ flex-direction: row;
+ }
+
+ .sidebar {
+ width: 250px;
+ height: 100vh;
+ position: sticky;
+ top: 0;
+ }
+
+ .top-row {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ }
+
+ .top-row.auth ::deep a:first-child {
+ flex: 1;
+ text-align: right;
+ width: 0;
+ }
+
+ .top-row, article {
+ padding-left: 2rem !important;
+ padding-right: 1.5rem !important;
+ }
+}
+
+#blazor-error-ui {
+ color-scheme: light only;
+ background: lightyellow;
+ bottom: 0;
+ box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ display: none;
+ left: 0;
+ padding: 0.6rem 1.25rem 0.7rem 1.25rem;
+ position: fixed;
+ width: 100%;
+ z-index: 1000;
+}
+
+ #blazor-error-ui .dismiss {
+ cursor: pointer;
+ position: absolute;
+ right: 0.75rem;
+ top: 0.5rem;
+ }
diff --git a/src/Doings.Web/View/Pages/AccessDeniedPage.razor b/src/Doings.Web/View/Pages/AccessDeniedPage.razor
new file mode 100644
index 0000000..c012548
--- /dev/null
+++ b/src/Doings.Web/View/Pages/AccessDeniedPage.razor
@@ -0,0 +1,43 @@
+@page "/Account/AccessDenied"
+@using Doings.Application.Auth
+@using Doings.Web.View.Layout
+@layout MinimalLayout
+@inject ApplicationUserFlowCredentialHelper User
+@inject NavigationManager NavMgr
+@attribute [AllowAnonymous]
+
+<div class="container" style="max-width: 40em;">
+ <h1>Access Denied (403 Forbidden)</h1>
+ <hr />
+ <p>
+ Access to this resource has been denied to <b>@_userEmail</b>. If this isn't you, please
+ <a href="/logout">log out</a> and try again with your own credentials.
+ </p>
+ <p>
+ If you're trying to access the service normally, please ensure that the above email has
+ a relevant Worker in D365, and that the Worker's IsActive status is set to Yes.
+ If you're trying to access a specific resource within the service, check that you have
+ the required claims.
+ </p>
+ @if (!string.IsNullOrWhiteSpace(ReturnUrl))
+ {
+ <p>
+ The page that sent you here recommends you return to
+ <a href="@ReturnUrl" target="_self">@NavMgr.ToAbsoluteUri(ReturnUrl)</a>.
+ </p>
+ }
+</div>
+
+@code
+{
+ [Parameter]
+ [SupplyParameterFromQuery]
+ public string ReturnUrl { get; set; } = string.Empty;
+
+ private string _userEmail = string.Empty;
+ protected override async Task OnInitializedAsync()
+ {
+ _userEmail = await User.GetLoggedInUserEmail();
+ await base.OnInitializedAsync();
+ }
+}
diff --git a/src/Doings.Web/View/Routes.razor b/src/Doings.Web/View/Routes.razor
index f756e19..8c0b06d 100644
--- a/src/Doings.Web/View/Routes.razor
+++ b/src/Doings.Web/View/Routes.razor
@@ -1,6 +1,19 @@
-<Router AppAssembly="typeof(Program).Assembly">
- <Found Context="routeData">
- <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
- <FocusOnNavigate RouteData="routeData" Selector="h1" />
- </Found>
-</Router>
+@using Doings.Web.View.Layout
+@using Microsoft.AspNetCore.Components.Authorization
+@using Doings.Web.View.Components
+
+<CascadingAuthenticationState>
+ <Router AppAssembly="typeof(Program).Assembly">
+ <Found Context="routeData">
+ <FocusOnNavigate RouteData="routeData" Selector="h1" />
+ <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
+ <NotAuthorized>
+ <RedirectComponent Uri="/Account/AccessDenied" />
+ </NotAuthorized>
+ <Authorizing>
+ <p>Authorizing...</p>
+ </Authorizing>
+ </AuthorizeRouteView>
+ </Found>
+ </Router>
+</CascadingAuthenticationState>
\ No newline at end of file
diff --git a/src/Doings.Web/View/_Imports.razor b/src/Doings.Web/View/_Imports.razor
index 22100d6..4824c23 100644
--- a/src/Doings.Web/View/_Imports.razor
+++ b/src/Doings.Web/View/_Imports.razor
@@ -7,4 +7,7 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Doings.Web
-@using Microsoft.FluentUI.AspNetCore.Components
\ No newline at end of file
+@using Microsoft.FluentUI.AspNetCore.Components
+
+@using Microsoft.AspNetCore.Authorization
+@attribute [Authorize(Policy = "DoingsAuthz")]
\ No newline at end of file