Note
Removing BlazorHybridApp (MAUI Blazor) .csproj from the solution because the build fails as workloads are no longer supported.
.NET 6.0, Blazor WebAssembly, Blazor Server, MAUI Blazor, ASP.NET Core Web API, IdentityServer4, OAuth 2.0
Setup a solution for a Blazor app supporting the hosting models for Blazor WebAssembly, Blazor Server and MAUI Blazor Hybrid, a WebApi for accessing data and an Identity Provider for authentication:
- Blazor WebAssembly - running client-side on the browser.
- Blazor Server - where updates and event handling are run on the server and managed over a SignalR connection.
- IdentityServer4 - an OpenID Connect and OAuth 2.0 framework for authentication.
- ASP.NET Core Web API - for accessing data repositories by authenticated users.
- Razor Class Library - for shared Razor components.
- Class Library - for shared classes and interfaces.
- Class Library - a services library for calling the WebApi.
- Class Library - a repository library for access to data behind the WebApi.
The following steps will setup the solution and its projects, using their default project templates (and the ubiquitous WeatherForecast example), available in Visual Studio.
- Core Class Library
- Repository Class Library
- IdentityProvider
- ASP.NET Core Web API
- Services Class Library
- Razor Class Library for Shared Components
- Blazor WebAssembly App
- Blazor Server App
- Running the Solution
- Add a Maui Blazor Hybrid Client
First create a solution with a Class Library for core classes and interfaces that will be shared across all projects. How we use these will become apparent later.
1.1. Create a Class Library called Core
1.2. Rename the solution to BlazorSolutionSetup
1.3. Delete Class1.cs
1.4. Create a folder called Model and inside it create the following classes:
public class TokenProvider
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
public string IdToken { get; set; }
}
public class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; }
}
1.5. Create a folder called Interface and inside it create the following interfaces:
public interface IWeatherForecastRepository
{
IEnumerable<WeatherForecast> GetWeatherForecasts();
}
public interface IWeatherForecastService
{
Task<IEnumerable<WeatherForecast>> GetWeatherForecasts();
}
Now create a Class Library for the data repository code.
2.1. Create a Class Library called Repository
2.2. Add a project reference to Core
2.3. Delete Class1.cs
2.4. Create a class called WeatherForecastRepository that implements IWeatherForecastRepository
public class WeatherForecastRepository : IWeatherForecastRepository
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild",
"Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
public IEnumerable<WeatherForecast> GetWeatherForecasts()
{
var rng = new Random();
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray();
}
}
Install the IdentityServer4 templates and create a project to provide authentication.
3.1 Open the Visual Studio Developer Command Prompt and change directory to the solution file BlazorSolutionSetup.
3.2. Install IdentityServer4 templates, create the IdentityProvider project and add it to the solution.
Note: When prompted, choose not to seed the database (N)
dotnet new -i IdentityServer4.Templates
dotnet new is4aspid -n IdentityProvider
dotnet sln add IdentityProvider
3.3. Add the following code to SeedData.cs, after the code for creating default users alice and bob. This will create the roles weatheruser
and blazoruser
. It will also give alice both roles, while bob will only be given the role of blazoruser
.
You can install sqlite and query the database that is created by SeedData.cs.
// additional code not shown for brevity...
var roleMgr = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var weatherUser = roleMgr.FindByNameAsync("weatheruser").Result;
if (weatherUser == null)
{
weatherUser = new IdentityRole
{
Id = "weatheruser",
Name = "weatheruser",
NormalizedName = "weatheruser"
};
var weatherUserResult = roleMgr.CreateAsync(weatherUser).Result;
if (!weatherUserResult.Succeeded)
{
throw new Exception(weatherUserResult.Errors.First().Description);
}
var aliceRoleResult = userMgr.AddToRoleAsync(alice, weatherUser.Name).Result;
if (!aliceRoleResult.Succeeded)
{
throw new Exception(aliceRoleResult.Errors.First().Description);
}
Log.Debug("weatheruser created");
}
else
{
Log.Debug("weatheruser already exists");
}
var blazorUser = roleMgr.FindByNameAsync("blazoruser").Result;
if (blazorUser == null)
{
blazorUser = new IdentityRole
{
Id = "blazoruser",
Name = "blazoruser",
NormalizedName = "blazoruser"
};
var blazorUserResult = roleMgr.CreateAsync(blazorUser).Result;
if (!blazorUserResult.Succeeded)
{
throw new Exception(blazorUserResult.Errors.First().Description);
}
var aliceRoleResult = userMgr.AddToRoleAsync(alice, blazorUser.Name).Result;
if (!aliceRoleResult.Succeeded)
{
throw new Exception(aliceRoleResult.Errors.First().Description);
}
var bobRoleResult = userMgr.AddToRoleAsync(bob, blazorUser.Name).Result;
if (!bobRoleResult.Succeeded)
{
throw new Exception(bobRoleResult.Errors.First().Description);
}
Log.Debug("blazoruser created");
}
else
{
Log.Debug("blazoruser already exists");
}
// additional code not shown for brevity...
3.4. Create and Seed the database:
- In launchSettings.json set
"commandLineArgs": "/seed"
. This will ensure the database is seeded at startup. - In the solution's properties window set IdentityProvider as a startup project.
- Compile and run the solution.
- Remove
"commandLineArgs": "/seed"
from launchSettings.json.
3.5. In launchSettings.json set the applicationUrl
to "applicationUrl": "https://localhost:5001"
:
{
"profiles": {
"SelfHost": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:5001"
}
}
}
3.6. In Config.cs:
- Add roles to the
IdentityResources
public static IEnumerable<IdentityResource> IdentityResources =>
new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResource("roles", "User role(s)", new List<string> { "role" })
};
- Replace the default scopes with a new
ApiScope
called weatherapiread
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new ApiScope("weatherapiread")
};
- Create a list of
ApiResources
an add a weatherapiApiReasource
public static IEnumerable<ApiResource> ApiResources =>
new ApiResource[]
{
new ApiResource("weatherapi", "The Weather API")
{
Scopes = new [] { "weatherapiread" }
}
};
- Replace the default client credentials with new client credentials for BlazorWebAssemblyApp and BlazorServerApp, which we will create later.
public static IEnumerable<Client> Clients =>
new Client[]
{
new Client
{
ClientId = "blazorwebassemblyapp",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false,
AllowedCorsOrigins = { "https://localhost:44310" },
AllowedScopes = { "openid", "profile", "weatherapiread" },
RedirectUris = { "https://localhost:44310/authentication/login-callback" },
PostLogoutRedirectUris = { "https://localhost:44310/" },
Enabled = true
},
new Client
{
ClientId = "blazorserverapp",
AllowedGrantTypes = GrantTypes.Code,
ClientSecrets = { new Secret("blazorserverappsecret".Sha256()) },
RequirePkce = true,
RequireClientSecret = false,
AllowedCorsOrigins = { "https://localhost:44300" },
AllowedScopes = { "openid", "profile", "weatherapiread" },
RedirectUris = { "https://localhost:44300/signin-oidc" },
PostLogoutRedirectUris = { "https://localhost:44300/signout-oidc" },
},
};
3.7. Create a custom implementation of IProfileService called ProfileService. This will add roles to the users claims.
public class ProfileService : IProfileService
{
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var nameClaim = context.Subject.FindAll(JwtClaimTypes.Name);
context.IssuedClaims.AddRange(nameClaim);
var roleClaims = context.Subject.FindAll(JwtClaimTypes.Role);
context.IssuedClaims.AddRange(roleClaims);
await Task.CompletedTask;
}
public async Task IsActiveAsync(IsActiveContext context)
{
await Task.CompletedTask;
}
}
3.8. In ConfigureServices
method of Startup
- Add
AddInMemoryApiResources(Config.ApiResources)
when adding the IdentityServer service withservices.AddIdentityServer
.
var builder = services.AddIdentityServer(options =>
{
// additional code not shown for brevity...
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryApiResources(Config.ApiResources)
.AddInMemoryClients(Config.Clients)
.AddAspNetIdentity<ApplicationUser>();
- Register the
ProfileService
services.AddTransient<IProfileService, ProfileService>();
Create an ASP.NET Core Web API for accessing the data repository and restrict access to authorized users.
4.1. Create an ASP.NET Core WebAPI project called WebApi.
4.2. Add project references to the following projects:
4.3 Add the following nuget package to enable the WebApi to receive an OpenID Connect bearer token:
Microsoft.AspNetCore.Authentication.JwtBearer
4.4 Set the sslPort
in launchSettings.json.
"sslPort": 44320
4.5. Delete the WeatherForecast.cs class
4.6 In the ConfigureServices
method of Startup:
- Register a scoped IWeatherForecastRepository with the concrete implementation WeatherForecastRepository
services.AddScoped<IWeatherForecastRepository, WeatherForecastRepository>();
- Add a CORS policy to enable Cross-Origin Requests to allow requests from a different origin to the WebApi. See Enable Cross-Origin Requests (CORS) for more details.
services.AddCors(options =>
{
options.AddPolicy("local",
builder =>
builder.WithOrigins("https://localhost:44300", "https://localhost:44310")
.AllowAnyHeader());
});
- Configure authentication with
AddAuthentication
. Set the authority to that of the IdentityProvider, and set the audience to weatherapi
By calling
AddJwtBearer
we configure authentication to to require a JWT bearer token in the header. Authentication is performed by extracting and validating the JWT token from the Authorization request header
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://localhost:5001";
options.Audience = "weatherapi";
});
4.7. In the Configure
method of Startup :
- After
app.UseRouting()
, but beforeapp.UseAuthorization()
, add the CORS middlewareapp.UserCors()
followed by the authentication middlewareapp.UseAuthentication()
.
Middleware order is important. See middleware order for more information.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// additional code not shown for brevity...
app.UseRouting();
app.UseCors("local");
app.UseAuthentication();
app.UseAuthorization();
// additional code not shown for brevity...
}
4.8. Change the WeatherForecastController.cs:
- Add the
[Authorize(Roles = "weatheruser")]
attribute, restricting access to users who have theweatheruser
role. - Add the
[EnableCors("local")]
attribute to enable cross origin requests for our blazor apps. - Inject an instance of IWeatherForecastRepository into the constructor and call
weatherForecastRepository.GetWeatherForecasts()
from inside theGet()
method.
[ApiController]
[EnableCors("local")]
[Route("[controller]")]
[Authorize(Roles = "weatheruser")]
public class WeatherForecastController : ControllerBase
{
private readonly ILogger<WeatherForecastController> logger;
private readonly IWeatherForecastRepository weatherForecastRepository;
public WeatherForecastController(
IWeatherForecastRepository weatherForecastRepository,
ILogger<WeatherForecastController> logger)
{
this.weatherForecastRepository = weatherForecastRepository;
this.logger = logger;
}
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
return weatherForecastRepository.GetWeatherForecasts();
}
}
Create a Class Library for services classes.
5.1. Create a Class Library called Services
5.2. Add a project reference to Core
5.3. Delete Class1.cs
5.4. Create a WeatherForecastService class that implements IWeatherForecastService
- Create two constructors:
- One constructor accepting an instance of
HttpClient
, which will be called from BlazorWebAssemblyApp. - The other constructor accepting instances of
HttpClient
andTokenProvider
, which will be called from BlazorServerApp. This constructor will also setuseAccessToken = true
.
- One constructor accepting an instance of
- In the
GetWeatherForecasts()
method, ifuseAccessToken
is true then add theBearer
token to theAuthorization
header of the outgoing request.
public class WeatherForecastService : IWeatherForecastService
{
private readonly HttpClient httpClient;
private readonly TokenProvider tokenProvider;
private readonly bool useAccessToken;
public WeatherForecastService(HttpClient httpClient)
{
this.httpClient = httpClient;
useAccessToken = false;
}
public WeatherForecastService(HttpClient httpClient, TokenProvider tokenProvider)
{
this.httpClient = httpClient;
this.tokenProvider = tokenProvider;
useAccessToken = true;
}
public async Task<IEnumerable<WeatherForecast>> GetWeatherForecasts()
{
if (useAccessToken)
{
var token = tokenProvider.AccessToken;
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
}
return await JsonSerializer.DeserializeAsync<IEnumerable<WeatherForecast>>
(await httpClient.GetStreamAsync($"WeatherForecast"),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
}
Create a Blazor WebAssembly project and convert it to a Razor Class Library for shared components.
6.1. Create a Blazor WebAssembly App called RazorComponents
6.2 Add a project reference to Core
6.3. Remove all the nuget packages installed by default and add the following nuget packages:
Microsoft.AspNetCore.Components.Authorization
Microsoft.AspNetCore.Components.Web
6.4. Convert the project to a Razor Class Library (RCL) by double-clicking the project and setting the Project Sdk
to Microsoft.NET.Sdk.Razor
. The project file should look like this:
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="5.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
</Project>
6.5. Replace the content of the _Imports.razor as follows:
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Core.Interface
@using Core.Model
@using RazorComponents
@using RazorComponents.Shared
6.6. Delete the files:
- Properties/launchSettings.json
- wwwroot/index.html
- sample-data/weather.json
- App.razor
- Program.cs
6.7. Rename MainLayout.razor to MainLayoutBase.razor and replace the contents with the following:
A RenderFragment represents a segment of UI content, implemented as a delegate. Here we let the consumers of MainLayoutBase.razor provide UI content for the LoginDisplayFragment and BodyFragment. The consumers will be the Blazor apps, BlazorWebAssemblyApp and BlazorServerApp, which we will create later.
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<div class="main">
<div class="top-row px-4 auth">
@LoginDisplayFragment
<a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a>
</div>
<div class="content px-4">
@BodyFragment
</div>
</div>
</div>
@code {
[Parameter]
public RenderFragment LoginDisplayFragment { get; set; }
[Parameter]
public RenderFragment BodyFragment { get; set; }
}
6.8. In FetchData.razor
- Remove
@inject HttpClient Http
- Add
@using Microsoft.AspNetCore.Components.Authorization
- Add
@inject AuthenticationStateProvider AuthenticationStateProvider
- Use role based AuthorizeView
<AuthorizeView Roles="weatheruser">
to display content based on the users permission - Inject an instance of the IWeatherForecastService.
- In the
OnInitializedAsync()
method fetch the weather forecast only if the user has theweatheruser
role claim. We do this to demonstrate claim checking using the AuthenticationStateProvider. An alternative approach would be to add a button inside the Authorized content of the AuthorizeView, which would only be visible if the user has the specified role.
See usage of the Authorize attribute.
"Only use [Authorize] on @page components reached via the Blazor Router. Authorization is only performed as an aspect of routing and not for child components rendered within a page. To authorize the display of specific parts within a page, use AuthorizeView instead."
Here we use role based authorization on the AuthorizeView.
@page "/fetchdata"
@using Microsoft.AspNetCore.Components.Authorization
@inject AuthenticationStateProvider AuthenticationStateProvider
<AuthorizeView Roles="weatheruser">
<Authorized>
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from the server.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
</Authorized>
<NotAuthorized>
<p>Only users in the <b><i>weatheruser</i></b> role can access this page.</p>
</NotAuthorized>
</AuthorizeView>
@code {
protected IEnumerable<WeatherForecast> forecasts;
[Inject]
public IWeatherForecastService WeatherForecastService { get; set; }
protected override async Task OnInitializedAsync()
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity.IsAuthenticated)
{
if (user.HasClaim(c => c.Type == "role" && c.Value.Equals("weatheruser")))
{
forecasts = await WeatherForecastService.GetWeatherForecasts();
}
}
}
}
6.9. Create User.razor razor component in the /Pages folder to show the logged in users claims.
@page "/user"
<AuthorizeView>
<Authorized>
<h2>
Hello @context.User.Identity.Name,
here's the list of your claims:
</h2>
<ul>
@foreach (var claim in context.User.Claims)
{
<li><b>@claim.Type</b>: @claim.Value</li>
}
</ul>
</Authorized>
<NotAuthorized>
<p>I'm sorry, I can't display anything until you log in</p>
</NotAuthorized>
</AuthorizeView>
6.10. In NavMenu.razor either show or hide the NavLink
components for Fetch data
and User
based the user's roles:
- Add a
NavLink
for theUser
component and wrap it with<AuthorizeView Roles="blazoruser">
- Wrap the
NavLink
for theFetch data
component with<AuthorizeView Roles="weatheruser">
// additional code not shown for brevity...
<AuthorizeView Roles="weatheruser">
<li class="nav-item px-3">
<NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink>
</li>
</AuthorizeView>
<AuthorizeView Roles="blazoruser">
<li class="nav-item px-3">
<NavLink class="nav-link" href="user">
<span class="oi oi-person" aria-hidden="true"></span> User
</NavLink>
</li>
</AuthorizeView>
// additional code not shown for brevity...
7.1. Create a Blazor WebAssembly project called BlazorWebAssemblyApp, setting the authentication type to Individual Accounts.
7.2. Add a reference to the following projects:
7.3. Add the following nuget package:
Microsoft.Extensions.Http
7.4. In _Imports.razor add the following using statement
@using RazorComponents.Shared
7.5. Set the sslPort
in launchSettings.json to the following:
"sslPort": 44310
7.6. Delete files:
- Pages/Counter.razor
- Pages/FetchData.razor
- Pages/Index.razor
- Shared/SurveyPromt.razor
- Shared/NavMenu.razor
- Shared/NavMenu.razor.css
- wwwroot/sample-data/weather.json
7.7. Create a folder called Account and inside create UserAccountFactory.cs, inheriting AccountClaimsPrincipalFactory. It will be registered when configuring authentication in Program.cs.
Identity Server sends multiple roles as a JSON array in a single role claim and the custom user factory creates an individual role claim for each of the user's roles.
public class UserAccountFactory : AccountClaimsPrincipalFactory<RemoteUserAccount>
{
public UserAccountFactory(IAccessTokenProviderAccessor accessor) : base(accessor)
{
}
public async override ValueTask<ClaimsPrincipal> CreateUserAsync(RemoteUserAccount account,
RemoteAuthenticationUserOptions options)
{
var user = await base.CreateUserAsync(account, options);
if (user.Identity.IsAuthenticated)
{
var identity = (ClaimsIdentity)user.Identity;
var roleClaims = identity.FindAll(identity.RoleClaimType).ToArray();
if (roleClaims != null && roleClaims.Any())
{
foreach (var existingClaim in roleClaims)
{
identity.RemoveClaim(existingClaim);
}
var rolesElem = account.AdditionalProperties[identity.RoleClaimType];
if (rolesElem is JsonElement roles)
{
if (roles.ValueKind == JsonValueKind.Array)
{
foreach (var role in roles.EnumerateArray())
{
identity.AddClaim(new Claim(options.RoleClaim, role.GetString()));
}
}
else
{
identity.AddClaim(new Claim(options.RoleClaim, roles.GetString()));
}
}
}
}
return user;
}
}
7.8. In Program.cs
- Replace the scoped
HttpClient
services registration with a named client calledwebapi
. Set the port number of theclient.BaseAddress
to44320
, which is the port for the WebApi - Add message handler
AuthorizationMessageHandler
usingAddHttpMessageHandler
and configure it for the scopeweatherapiread
. This will ensure theaccess_token
withweatherapiread
is added to outgoing requests when using thewebapi
HttpClient.
builder.Services.AddHttpClient("webapi", (sp, client) =>
{
client.BaseAddress = new Uri("https://localhost:44320");
}).AddHttpMessageHandler(sp =>
{
var handler = sp.GetService<AuthorizationMessageHandler>()
.ConfigureHandler(
authorizedUrls: new[] { "https://localhost:44320" },
scopes: new[] { "weatherapiread" });
return handler;
});
- Register transient service of type IWeatherForecastService with implementation type WeatherForecastService, injecting and instance of
HttpClient
using theIHttpClientFactory
, into its constructor.
builder.Services.AddTransient<IWeatherForecastService, WeatherForecastService>(sp =>
{
var httpClient = sp.GetRequiredService<IHttpClientFactory>();
var weatherForecastServiceHttpClient = httpClient.CreateClient("webapi");
return new WeatherForecastService(weatherForecastServiceHttpClient);
});
- Register and configure authentication replacing
builder.Services.AddOidcAuthentication
and set the port number of theoptions.ProviderOptions.Authority
to5001
, which is the port for the IndentityProvider. Replace the existing AccountClaimsPrincipleFactory with UserAccountFactory.
builder.Services.AddOidcAuthentication(options =>
{
//// Configure your authentication provider options here.
//// For more information, see https://aka.ms/blazor-standalone-auth
//builder.Configuration.Bind("Local", options.ProviderOptions);
options.ProviderOptions.Authority = "https://localhost:5001/";
options.ProviderOptions.ClientId = "blazorwebassemblyapp";
options.ProviderOptions.DefaultScopes.Add("openid");
options.ProviderOptions.DefaultScopes.Add("profile");
options.ProviderOptions.DefaultScopes.Add("weatherapiread");
options.ProviderOptions.PostLogoutRedirectUri = "/";
options.ProviderOptions.ResponseType = "code";
options.UserOptions.RoleClaim = "role";
}).AddAccountClaimsPrincipalFactory<UserAccountFactory>();
7.9. In App.razor add typeof(NavMenu).Assembly
to the AdditionalAssemblies
of the Router
so the RazorComponents assembly will be scanned for additional routable components that can match URIs.
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly"
AdditionalAssemblies="new[] { typeof(NavMenu).Assembly}" PreferExactMatches="@true">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (!context.User.Identity.IsAuthenticated)
{
<RedirectToLogin />
}
else
{
<p>You are not authorized to access this resource.</p>
}
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
7.10. Replace the contents of MainLayout.razor with the following. This uses the shared MainLayoutBase.razor in RazorComponents, passing in UI contents LoginDisplay
and @Body
as RenderFragment
delegates.
@inherits LayoutComponentBase
<MainLayoutBase>
<LoginDisplayFragment>
<LoginDisplay/>
</LoginDisplayFragment>
<BodyFragment>
@Body
</BodyFragment>
</MainLayoutBase>
8.1. Create a Blazor Server project called BlazorServerApp, setting the authentication type to Individual Accounts.
8.2. Add a reference to the following projects:
8.3. Uninstall the following nuget packages:
Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore
Microsoft.AspNetCore.Identity.EntityFrameworkCore
Microsoft.AspNetCore.Identity.UI
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools
8.4. Install the following nuget packages:
IdentityModel
Microsoft.AspNetCore.Authentication.OpenIdConnect
8.5. In _Imports.razor add the following using statement
@using RazorComponents.Shared
8.6. Set the sslPort
in launchSettings.json to the following:
"sslPort": 44300
8.7. Delete the Data folder and it's content:
8.8. Delete files:
- Pages/Counter.razor
- Pages/FetchData.razor
- Pages/Index.razor
- Shared/SurveyPromt.razor
- Shared/NavMenu.razor
- Shared/NavMenu.razor.css
- Areas/Identity/Pages/RevalidatingIdentityAuthenticationStateProvider.cs
- Areas/Identity/Pages/Shared/_LoginPartial.cshtml
8.9. In the ConfigureServices
method of Startup:
- Remove the following default configuration:
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
services.AddDefaultIdentity<IdentityUser>(options =>
options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddScoped<AuthenticationStateProvider,
RevalidatingIdentityAuthenticationStateProvider<IdentityUser>>();
services.AddDatabaseDeveloperPageExceptionFilter();
services.AddSingleton<WeatherForecastService>();
- Clear the default claim mapping so the claims don't get mapped to different claims
public void ConfigureServices(IServiceCollection services)
{
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
// additional code not shown for brevity...
- Configure authentication with
AddAuthentication
. Set the port number of theoptions.Authority
to5001
, which is the port for the IndentityProvider.
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Authority = "https://localhost:5001/";
options.ClientId = "blazorserverapp";
options.ClientSecret = "blazorserverappsecret";
options.ResponseType = "code";
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("weatherapiread");
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.ClaimActions.Add(new JsonKeyClaimAction("role", "role", "role"));
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};
});
- Add a named
HttpClient
calledwebapi
. Set the port number of theclient.BaseAddress
to44320
, which is the port for the WebApi
services.AddHttpClient("webapi", client =>
{
client.BaseAddress = new Uri("https://localhost:44320");
});
- Register a scoped service for TokenProvider.
services.AddScoped<TokenProvider>();
- Register transient service of type IWeatherForecastService, with implementation type WeatherForecastService. Inject into its constructor an instance of the
TokenProvider
and the namedHttpClient
calledwebapi
.
services.AddTransient<IWeatherForecastService, WeatherForecastService>(sp =>
{
var tokenProvider = sp.GetRequiredService<TokenProvider>();
var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("webapi");
return new WeatherForecastService(httpClient, tokenProvider);
});
8.10. In the Configure
method of Startup remove app.UseMigrationsEndPoint();
8.11. Create a folder called Model
and inside create a class called InitialApplicationState
public class InitialApplicationState
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
public string IdToken { get; set; }
}
8.12. In the _Host.cshtml:
- Add code to get the access token into an instance of
InitialApplicationState
calledtokens
. - Pass the tokens into the
App
component by setting it'sparam-InitialState
to thetokens
.
@page "/"
@using Microsoft.AspNetCore.Authentication
@using BlazorServerApp.Model
@namespace BlazorServerApp.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
Layout = null;
var initialState = new InitialApplicationState
{
AccessToken = await HttpContext.GetTokenAsync("access_token"),
RefreshToken = await HttpContext.GetTokenAsync("refresh_token"),
IdToken = await HttpContext.GetTokenAsync("id_token")
};
}
// additional code not shown for brevity...
<body>
<component type="typeof(App)" param-InitialState="initialState" render-mode="ServerPrerendered" />
// additional code not shown for brevity...
</body>
</html>
8.13. In the Shared folder create a razor component called RedirectToLogin:
Note: the optional
forceLoad
parameter inNavigation.NavigateTo
must betrue
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
Navigation.NavigateTo($"Identity/Account/Login?redirectUri={Uri.EscapeDataString(Navigation.Uri)}", true);
}
}
8.14. In App.razor:
- Inject the
TokenProvider
, create a parameter forInitialApplicationState
and inOnInitializedAsync
set the access token: - Add
typeof(NavMenu).Assembly
to theAdditionalAssemblies
of theRouter
so the RazorComponents assembly will be scanned for additional routable components. - Add
<RedirectToLogin />
inside the<NotAuthorized>
of the<AuthorizeRouteView>
.
@using Core.Model
@using BlazorServerApp.Model
@inject TokenProvider TokenProvider
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly"
AdditionalAssemblies="new[] { typeof(NavMenu).Assembly}"
PreferExactMatches="@true">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (!context.User.Identity.IsAuthenticated)
{
<RedirectToLogin />
}
else
{
<p>You are not authorized to access this resource.</p>
}
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
@code {
[Parameter]
public InitialApplicationState InitialState { get; set; }
protected override Task OnInitializedAsync()
{
TokenProvider.AccessToken = InitialState.AccessToken;
TokenProvider.RefreshToken = InitialState.RefreshToken;
TokenProvider.IdToken = InitialState.IdToken;
return base.OnInitializedAsync();
}
}
8.15. Replace the contents of MainLayout.razor with the following. This uses the shared MainLayoutBase.razor in RazorComponents, passing in UI contents LoginDisplay
and @Body
as RenderFragment delegates.
@inherits LayoutComponentBase
<MainLayoutBase>
<LoginDisplayFragment>
<LoginDisplay />
</LoginDisplayFragment>
<BodyFragment>
@Body
</BodyFragment>
</MainLayoutBase>
8.16. Add a Razor page called Login.chtml in the folder \Areas\Identity\Pages\Account\Login.chtml and in Login.cshtml.cs update the OnGetAsync
as follows:
public class LoginModel : PageModel
{
public async Task OnGetAsync(string redirectUri)
{
if(string.IsNullOrWhiteSpace(redirectUri))
{
redirectUri = Url.Content("~/");
}
if(HttpContext.User.Identity.IsAuthenticated)
{
Response.Redirect(redirectUri);
}
await HttpContext.ChallengeAsync(
OpenIdConnectDefaults.AuthenticationScheme,
new AuthenticationProperties { RedirectUri = redirectUri });
}
}
8.17. In LoginDisplay.cshtml:
- remove the hyperlink
<a href="Identity/Account/Manage">Hello, @context.User.Identity.Name!</a>
around the user's name. - remove
<a href="Identity/Account/Register">Register</a>
<AuthorizeView>
<Authorized>
Hello, @context.User.Identity.Name!
<form method="post" action="Identity/Account/LogOut">
<button type="submit" class="nav-link btn btn-link">Log out</button>
</form>
</Authorized>
<NotAuthorized>
<a href="Identity/Account/Login">Log in</a>
</NotAuthorized>
</AuthorizeView>
8.18. Change LogOut.cshtml to explicitly sign out using HttpContext.SignOutAsync()
and send a request to end the session with the identity provider, passing the id_token
and postLogoutRedirectUri
:
@page
@using IdentityModel.Client;
@using Microsoft.AspNetCore.Authentication;
@using Microsoft.AspNetCore.Authentication.Cookies;
@attribute [IgnoreAntiforgeryToken]
@functions {
public async Task<IActionResult> OnPost()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var idToken = await HttpContext.GetTokenAsync("id_token");
var requestUrl = new RequestUrl("https://localhost:5001/connect/endsession");
var url = requestUrl.CreateEndSessionUrl(idTokenHint: idToken, postLogoutRedirectUri: "https://localhost:44300/");
return Redirect(url);
}
}
9.1. In the solution's properties window select Multiple startup projects and set the Action of the following projects to Startup:
9.2. Compile and run the solution.
9.3. Login using IdentityServer4 default users, bob or alice using the default password: Pass123$
9.4. If you login as alice you can see the Fetch data
navigation link. This is because she is in the weatheruser
role and she has permission to fetch the weather data. If you login as bob you can't see the Fetch data
navigation link because he isn't in the weatheruser
role and is not authorized to fetch the weather data. Both alice and bob are in the blazoruser
role so they can both see the User
navigation link and view their claims.
Frst follow the Auth0 example for authenticating the user with Auth0.
Then follow ASP.NET Core Blazor Hybrid authentication and authorization to create a custom AuthenticationStateProvider called Auth0AuthenticationStateProvider.cs.
Note: In LogInAsync find the role claim where RoleClaim = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" and re-add the claim as Role.
public async Task LogInAsync()
{
var loginRequest = new LoginRequest { FrontChannelExtraParameters = new Parameters(options.AdditionalProviderParameters) };
var loginResult = await oidcClient.LoginAsync(loginRequest);
tokenProvider.RefreshToken = loginResult.RefreshToken;
tokenProvider.AccessToken = loginResult.AccessToken;
tokenProvider.IdToken = loginResult.IdentityToken;
currentUser = loginResult.User;
if (currentUser.Identity.IsAuthenticated)
{
var identity = (ClaimsIdentity)currentUser.Identity;
if (identity.RoleClaimType != options.RoleClaim)
{
var roleClaims = identity.FindAll(options.RoleClaim).ToArray();
if (roleClaims != null && roleClaims.Any())
{
foreach (var roleClaim in roleClaims)
{
identity.RemoveClaim(roleClaim);
}
foreach (var roleClaim in roleClaims)
{
identity.AddClaim(new Claim(identity.RoleClaimType, roleClaim.Value));
}
}
}
}
NotifyAuthenticationStateChanged(
Task.FromResult(new AuthenticationState(currentUser)));
}
Configure authentication in MauiProgram.cs.
builder.Services.AddAuthorizationCore();
builder.Services.AddSingleton<TokenProvider>();
builder.Services.AddScoped<Auth0AuthenticationStateProviderOptions>();
builder.Services.AddScoped<Auth0AuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp =>
{
var tokenProvider = sp.GetRequiredService<TokenProvider>();
var auth0AuthenticationStateProviderOptions = sp.GetRequiredService<Auth0AuthenticationStateProviderOptions>();
auth0AuthenticationStateProviderOptions.Domain = "<YOUR_AUTH0_DOMAIN>";
auth0AuthenticationStateProviderOptions.ClientId = "<YOUR_CLIENT_ID>";
auth0AuthenticationStateProviderOptions.AdditionalProviderParameters.Add("audience", "<YOUR_AUDIENCE>");
auth0AuthenticationStateProviderOptions.Scope = "openid profile";
auth0AuthenticationStateProviderOptions.RoleClaim = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
auth0AuthenticationStateProviderOptions.RedirectUri = "myapp://callback";
//auth0AuthenticationStateProviderOptions.RedirectUri = "http://localhost/callback"; // https://github.com/dotnet/maui/issues/8382
return sp.GetRequiredService<Auth0AuthenticationStateProvider>();
});
Finally, connect from Android emulator to the web api on local host - bypassing SSL connections to localhost on Android by creating DevHttpClientHelperExtensions.
- dotnet/maui#8131
- https://gist.github.com/Eilon/49e3c5216abfa3eba81e453d45cba2d4
- https://gist.github.com/EdCharbeneau/ed3d44d8298319c201f276de7a0580f1
- https://www.youtube.com/watch?v=jcw-YBrwuZQ
Register the dev HttpClient in MauiProgram.cs.
#if DEBUG
builder.Services.AddLocalDevHttpClient("webapi", 44320);
#else
builder.Services.AddHttpClient("webapi", client =>
{
client.BaseAddress = new Uri("https://localhost:44320");
});
#endif
NOTE: There is currently a known issue using WebAuthenticator on Windows.
NOTE: If there is a NullReferenceException on CallbackResult you may need to add the following part into your AndroidManifest.xml file between the
<manifest>
tags.
Use IHttpClientFactory to configure and create HttpClient instances because it manages the pooling and lifetime of underlying HttpClientMessageHandler
instances. Automatic management avoids common DNS (Domain Name System) problems that occur when manually managing HttpClient lifetimes, including:
- Socket exhaustion - each
HTTPClient
instance creates a new socket instance which isn't released immediately, even inside ausing
statement, and may lead to socket exceptions. - Stale DNS (Domain Name System) - when a computer is removed from the domain or is unable to update its DNS record in the DNS Server, the DNS record of that Windows computer remains in the DNS database and is considered to be a stale DNS record.
Unlike Blazor WebAssemby, which has AuthorizationMessageHandler
, Blazor Server doesn't have a built in message handler for adding a access_token
to outgoing requests.
The lifetime of a message handler is controlled by the IHttpClientFactory
, which keeps it open for two minutes even, even if we register a custom message handler as Transient. Everything we inject into a custom message handler will be scoped to the message handler, rather than scoped to the HTTP request. This is why we can't inject into the custom message handler the HTTP scoped TokenProvider
, in order to add the access_token
to outgoing requests.
IHttpClientFactory
does, however, manage the lifetime of message handlers seperately from instances of HttpClient
that it creates. We can inject an instance of HttpClient
and the HTTP scoped TokenProvider
, which has the access_token
, into WeatherForecastService. We can then add the access_token
to outgoing requests from within the WeatherForecastService.