I'm having an issue with Policy authentication in my .Net 8 Blazor hybrid application using Microsoft Entra for authentication.
The entra authentication is working and I have a page that verifies policies are working via an <AuthorizeView>
and claims are set.
My layout page has this block which enforces the user be logged in or is sent to the Entra login flow:
<AuthorizeView Policy="@PolicyConstants.MustBeAuthenticated">
<Authorized>
@Body
</Authorized>
<NotAuthorized>
@{
var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
<RedirectToEntraSignInComponent ReturnUrl="@returnUrl" />
}
</NotAuthorized>
</AuthorizeView>
In my auth model, I have two Roles: Admin and Client. I have policies defined for each one, and on my root page I have this nested AuthorizeView
so I can forward the user based on their role. This is working to send them to the right page.
<CascadingAuthenticationState>
<AuthorizeView Policy="@PolicyConstants.IsAdmin" Context="AdminContext">
<Authorized>
<RedirectToAdminDashboardComponent />
</Authorized>
<NotAuthorized>
<AuthorizeView Policy="@PolicyConstants.IsClient" Context="ClientContext">
<Authorized>
<RedirectToClientDashboardComponent />
</Authorized>
<NotAuthorized>
<RedirectTo403Component />
</NotAuthorized>
</AuthorizeView>
</NotAuthorized>
</AuthorizeView>
</CascadingAuthenticationState>
So, an admin ends up on their dashboard. But, I need to protect that page to ensure only an Admin can access it.
[Authorize(Policy = PolicyConstants.IsAdmin)]
public partial class AdminDashboardPage
When I do not have the authorize attribute in place, it works fine, but the page is open to anyone who is authenticated, regardless of their roles/policy status.
When I add the Authorize attribute to the page as above, the user is always forwarded to this url, even when they meet the policy requirement to get to the page in the first place.
/MicrosoftIdentity/Account/AccessDenied?ReturnUrl=%2Fadmin
I want to avoid putting a authorize view on each page to enforce policy, and I don't want to have to have a layout page with the auth view for every policy either. I don't understand why the auth attribute is failing every time. I did try defining the policies in the client and server projects, but that did not fix the problem.
The solution I found for this issue is that Microsoft.Identity and ASP.NET core authorization do not play together at all.
The <AuthorizeView>
attributes are working via policy through our Microsoft.Identity configuration.
The [Authorize]
attribute is using ASP.NET core authorization.
When we authenticate in, we are authenticating via Microsoft.Identity only. I believe this is because the auth cookie is not shared between Identity and .Net Core. If you land on an unprotected page after going through an auth process, the app will work until the page reloads. On a full page load, any authstate in .Net core is lost, causing the issue in the original question.
For our solution, we created a middleware to pass-through on .Net core Auth. Without this, on a full page load, authentication state is lost, the user fails any authorization, and the user is kicked out before they even get to the <AuthorizeView>
public class BlazorAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler
{
public Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult)
{
return next(context);
}
}
Then, register the middlware in your DI
services.AddSingleton<IAuthorizationMiddlewareResultHandler, BlazorAuthorizationMiddlewareResultHandler>();
And to control the URL where unauthorized users are sent due to an [Authorize]
attribute, you can add the following:
services.Configure<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme, options => {
options.AccessDeniedPath = new PathString("/errors/AccessDenied");
options.ReturnUrlParameter = "returnUrl";
});
Our authorize Attributes are working because we are using Policy with custom logic
public class IsAdminRequirement : AuthorizationHandler<IsAdminRequirement>, IAuthorizationRequirement
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IsAdminRequirement requirement)
{
if (context.User.IsAdmin()) //this is a custom extension method that evaluates claims
{
context.Succeed(requirement);
}
else
{
context.Fail(new AuthorizationFailureReason(this, $"User is not an Admin"));
}
return Task.CompletedTask;
}
}
When you have a requirement like the above that does not need any resources injected, the auth framework will register it automatically. If you needed to inject a service, you would break it out into two classes and register the handler via DI:
// NOTE: If a handler implements both IAuthorizationHandler and IAuthorizationRequirement,
// it does not need to be registered due to auto-registration that happens within
// the auth framework. Only register below those handlers that need DI injection
services.AddScoped<IAuthorizationHandler, MyCustomRequirementHandler>();
And then in our DI we register Authorization Filters
services.AddRazorPages().AddMvcOptions(options =>
{
options.Filters.Add(new AuthorizeFilter(PolicyConstants.IsAdmin));
}).AddMicrosoftIdentityUI();
And then register the policy requirement and tie it to a string constant
services.AddAuthorizationBuilder()
.AddPolicy(PolicyConstants.IsAdmin,
policy => policy.AddRequirements(new IsAdminRequirement()))
Now, we can use our policy in AuthorizeView
to show/hide content and via [Authorize]
attribute to restrict access.