Sample Blazor Server Application (with IdentityServer and API)
This example solution demonstrates how to:
- Integrate a Blazor Server application with IdentityServer and ASP.NET Identity using auth code flow with PKCE protection.
- Configure a custom user claim in Identity Server and propagate it to the application cookie during user authentication.
- Include the custom user claim in the access token when calling the API.
This application provides two protected features that allow the user to view all claims that have been assigned, and to differentiate between the Application user claims set and the API user claims set.
Navigation Menu Item: displays the claims of the current User identity for the application.
Navigation Menu Item: calls a test API, which is protected by IdentityServer. The API will return the user claims it received with the request as JSON. The application then displays those claims to the User.
The sample solution demonstrates 4 layers of security:
- Application Routing: Block application route paths for unauthorized users
- Application Navigation: Hide navigation links for unauthorized users
- API User Deny API access to unauthorized users
- API Client Deny API access to unauthorized clients
Create the IdentityServer project using the IdentityServer and .NET Identity project template is4aspid
dotnet new is4aspid -n IdentityServerAspNetIdentity
Users and claims for testing are created in SeedData.cs.
For the Alice user only, add a custom claim of type appUser_claim with value identity
SeedData.cs
result = userMgr.AddClaimsAsync(alice, new Claim[]{
new Claim(JwtClaimTypes.GivenName, "Alice"),
new Claim(JwtClaimTypes.FamilyName, "Smith"),
new Claim(JwtClaimTypes.Email, "AliceSmith@email.com"),
new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean),
new Claim(JwtClaimTypes.WebSite, "http://alice.com"),
new Claim(JwtClaimTypes.Address,
@"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', \
'postal_code': 69118, 'country': 'Germany' }",
IdentityServer4.IdentityServerConstants.ClaimValueTypes.Json),
// Add user_scope claim for Identity to authorize UI and API actions.
// Alice has this claim, Bob does not.
new Claim("appUser_claim","identity")
A custom Identity Resource is required in IdentityServer to control access to the custom claim type appUser_claim for client applications and apis.
Config.cs
new List<IdentityResource>
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email(),
// Identity Resource for custom user claim type
new IdentityResource("appUser_claim", new []{"appUser_claim"})
};
A custom API Resource is required in IdentityServer to control access to the API and specify
which user claims should be included in the access token.
Config.cs
new List<ApiResource>
{
// Identity API, consumes user claim type 'appUser_claim'
// Claim Types are the associated user claim types required by this resource (api).
// Identity Server will include those claims in Access tokens for this resource when available.
new ApiResource(
"identityApi", // Name
"Identity Claims Api", // Display Name
new []{"appUser_claim"}) // Claim Types
};
A client must be configured in Identity Server that has access to the API Resource and the Identity Resource.
Config.cs
// interactive ASP.NET Core Blazor Server Client
new Client
{
ClientId = "BlazorID_App",
ClientName="Blazor Server App - Identity Claims",
ClientSecrets = { new Secret("secret".Sha256()) },
// Use Code flow with PKCE (most secure)
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
// Do not require the user to give consent
RequireConsent = false,
// where to redirect to after login
RedirectUris = { "https://localhost:44321/signin-oidc" },
// where to redirect to after logout
PostLogoutRedirectUris = { "https://localhost:44321/signout-callback-oidc" },
// Allowed Scopes - include Api Resources and Identity Resources that may be accessed by this client
// The identityApi scope provides access to the API, the appUser_claim scope provides access to the
// custom Identity Resource
AllowedScopes = { "openid", "profile", "email", "identityApi","appUser_claim" },
// AllowOfflineAccess includes the refresh token
// The application will get a new access token after the old one expires without forcing the user
// to sign in again.
// Token management is done by the middleware, but the client must be allowed access here and the
// offline_access scope must be added in the OIDC settings in client Startup.ConfigureServices
AllowOfflineAccess = true
}
The demo API was created from the standard ASP.NET Core Web API template.
dotnet new web -n Api
Add a new Controller to the project named IdentityController with the following code:
IdentityController.cs
//create base controller route
[Route("api/identity")]
// This authorize attribute challenges all clients attempting to access all controller methods.
// Clients must posses the client scope claim "identityApi" (api resource in IdentityServer)
// It is not actually required in this specific case, because there is only one method and it has
// its own Authorize attribute.
// However, it is a common practice to have this controller level attribute to ensure that
// Identity Server is protecting the entire controller, including methods that may be added in the
// future.
[Authorize]
public class IdentityController : ControllerBase
{
[HttpGet]
// Use authorization to protect the api GET method that is used to protect the application feature
[Authorize]
public IActionResult Get()
{
// return the claim set of the current API user as Json
return new JsonResult(from c in User.Claims select new { c.Type, c.Value });
}
}
Startup.cs:ConfigureServices
services
.AddControllers()
.AddNewtonsoftJson();
// configure bearer token authentication
services
.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
// IdentityServer url
options.Authority = "https://localhost:44387";
// RequireHttpsMetadata must be true in production
options.RequireHttpsMetadata = false;
// Audience is api Resource name
options.Audience = "identityApi";
});
Startup.cs:Configure
public void Configure(IApplicationBuilder app)
{
app.UseRouting();
// add authentication first, followed by authorization
// these two should come after app.UseRouting but before app.UseEndpoints
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
The demo Blazor Server App was created from the standard ASP.NET Core Blazor Server template.
dotnet new blazorserver -n BlazorId_App
Configure Authentication (OIDC) and Authorization services
Startup.ConfigureServices
services.AddAuthentication(options =>
{
// the application's main authentication scheme will be cookies
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// the authentication challenge will be handled by the OIDC middleware, and ultimately
// IdentityServer
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme,
options =>
{
options.Authority = "https://localhost:44387/";
options.ClientId = "BlazorID_App";
options.ClientSecret = "secret";
options.UsePkce = true;
options.ResponseType = "code";
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("offline_access");
//Scope for accessing API
options.Scope.Add("identityApi");
// Scope for custom user claim
options.Scope.Add("appUser_claim");
// map custom user claim
options.ClaimActions.MapUniqueJsonKey("appUser_claim", "appUser_claim");
//options.CallbackPath = ...
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
});
...
Add services to the request pipeline in correct processing order:
- UseStaticFiles
- UseRouting
- UseAuthentication
- UseAuthorization
- UseEndpoints
Startup.Configure
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// add authentication first, followed by authorization
// these should come after app.UseRouting and before app.UseEndpoints
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
});
A Blazor component cannot correctly redirect to the IdentityServer Login and Login functions on its own.
For signing in and out, the HttpResponse must be modified by adding a cookie - but a pure Blazor component starts the response immediately when it is rendered and it cannot be changed afterward.
An intermediary razor page (or MVC view) must be used to interact with the OIDC middleware for logging in and out because the page is able to manipulate the response correctly before sending it.
These pages have a cs file only, with no markup code, and each has a single Get method that performs the required actions.
The real login and logout pages are centrally located in Identity Server.
The LoginIDP page invokes the ChallengeAsync method on the OIDC scheme, triggering the redirect to IdentityServer for Authentication.
LoginIDP.cshtml.cs
public async Task OnGetAsync()
{
if (!HttpContext.User.Identity.IsAuthenticated)
{
//Call the challenge on the OIDC scheme and trigger the redirect to IdentityServer
await HttpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme);
}
else
{
// redirect to the root
Response.Redirect(Url.Content("~/").ToString());
}
}
The LogoutIDP page invokes the SignOutAsync method for both Authentication Schemes (Cookies and OIDC)
LogoutIDP.Razor
public async Task OnGetAsync()
{
// Sign out of Cookies and OIDC schemes
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
}
This sample project is using LoginIDP and LogoutIDP razor pages provided by Nuget package
BlazorRazor
BlazorID_App.csproj
<ItemGroup>
<PackageReference Include="BlazorRazor" Version="1.0.0" />
After referencing this nuget package, simply direct logins to "/LoginIDP" and logouts to "/LogoutIDP".
_NavMenu.razor
<NavLink class="nav-link" href="/LoginIDP"> Log in </NavLink>
<NavLink class="nav-link" href="/LogoutIDP"> Log out </NavLink>
- Razor components support the use of Authorize attributes to trigger authorization checks on the component.
- Authorization results are cascaded down through all children of CascasdingAuthenticationState.
Identity-Api.razor
- The Authorize attribute in the Identity-Api component performs an Authorization check when a user attempts to access the component.
@page "/identityapi"
@attribute [Authorize]
- Authentication in SignalR apps is established with the initial connection.
- The CascadingAuthenticationState component receives the authentication information upon intial connection and cascades this information to all descendant components.
- Configured in App.razor
- Controls access to application routes based on the user's authorization status.
- Prevents direct navigation to an unauthorized page by entering the URI in the browser.
- The protected component must contain the @Page directive meaning it is a routable component.
- The protected component must contain an Authorization attribute that is used to the generate authorization status.
- AuthorizeRouteView is configured in the App.Razor file.
App.razor
- The AuthorizeRouteView element is wrapped in the CascadingAuthenticationState element, and thus can access the authentication and authorization status data.
- When the authorization fails, the code in the NotAuthorized element is activated and a denial message is returned to the caller instead of the page.
- When the authorization succeeds, the code in the NotAuthorized element is not activated and the requeste is returned as usual.
App.razor
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<h1>Sorry, you're not authorized to view this page.</h1>
<p>You may want to try logging in (as someone with the necessary authorization).</p>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
- Organizes razor code into two sections, Authorized and NotAuthorized
- When authorization succeeds, the code in the Authorized section is activated and the markup content generated within that section will be rendered.
- When the authorization fails, the code in the NotAuthorized section is activated and the razor code within that section will be rendered.
- Used in NavMenu.razor to hide navigation links for unauthorized users.
NavMenu.razor
- The authorized user sees all Links except Login
- The unauthorized user only sees the Login link
NavMenu.razor
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<ul class="nav flex-column">
<AuthorizeView>
<Authorized>
<li class="nav-item px-3">
<NavLink class="nav-link"
href="/" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="identityapp">
<span class="oi oi-list-rich" aria-hidden="true"></span> APP Identity
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="identityapi">
<span class="oi oi-list-rich" aria-hidden="true"></span> API Identity
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link"
href="/LogoutIDP">
<span class="oi oi-list-rich" aria-hidden="true"></span> Log out
(@context.User.Claims.FirstOrDefault(c => c.Type == "name")?.Value)
</NavLink>
</li>
</Authorized>
<NotAuthorized>
<NavLink class="nav-link"
href="/LoginIDP">
<span class="oi oi-list-rich" aria-hidden="true"></span> Log in
</NavLink>
</NotAuthorized>
</AuthorizeView>
</ul>
</div>