Blazor WebApp OIDC Authentication
Vou compartilhar um pouco da experiência com autenticação usando Blazor ;)
Começamos a utilizar Blazor para construir nossas aplicações internas na empresa onde eu trabalho, então começamos a montar um template com funcionalidades padrão:
- Autenticação
- Autorização
- Fluent UI Blazor
Esse template vai dar agilidade e qualidade para os novos projetos, então sempre é legal seguir essa estratégia. Fizemos a mesma coisa para Workers e WebAPI
Autenticação
O código fonte está acessível nesse link
O ponto que mais deu trabalho foi autenticação, pois é possível encontrar muitos conteúdos usando WebAssembly, Individual Accounts, mas nós vamos utilizar simplesmente Blazor WebServer com server render e code-behind, simples e funcional!
Encontrei um artigo do Andrew Lock que me deu um norte, mas precisava de algo que funcionasse com a versão do .NET8.
Então usei a mesma estratégia em conjunto com a documentação explicando como implementar OIDC sem o padrão BFF da Microsoft
Vamos inciar criando o projeto utilizando o template do Fluent UI com a configuração padrão:
dotnet new fluentblazor --interactivity Server --all-interactive -n BlazorAuthDemo
cd BlazorAuthDemo
dotnet run --launch-profile https
Para mais informações sobre esse template consulte aqui
Vamos configurar a infra do nosso projeto, iniciando pelo pacote que adiciona autenticação OIDC no ASPNET Core:
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
Vamos adicionar os middlewares de autenticação e autorização no Program.cs:
using Microsoft.FluentUI.AspNetCore.Components;
using BlazorAuthDemo.Components;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddFluentUIComponents();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAuthentication(); // <- Aqui
app.UseAuthorization(); // <- Aqui
app.UseAntiforgery(); // <- Alteramos tbm a posição desse middleware,
// isso é importante para que o anti-forgery-token
// seja gerado utilizando a identidade do usuário
// do contexto http
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
E também precisamos configurar o ASPNET Core para utilizar OIDC + Cookie:
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddOpenIdConnect(options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Authority = "<authority_url>";
options.ClientId = "<client_id>";
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true; //Precisamos persistir para utilizar posteriormente
// Aqui precisamos configurar como o fluxo irá realizar o bind da propriedade Name da identidade
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "given_name";
// Quando fizermos o redirect do fluxo de logout precisamos enviar o token para o provedor de identidade
// Alguns provedores requerem esse parâmetro
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProviderForSignOut = async (context) =>
{
context.ProtocolMessage.IdTokenHint = await context.HttpContext.GetTokenAsync(OpenIdConnectResponseType.IdToken);
}
};
})
.AddCookie()
;
Aqui não entrarei em muitos detalhes sobre OIDC ou OAuth, pois estou assumindo que você já tem conhecimentos prévios sobre esses assuntos
Vamos adicionar também um root-level cascading value
builder.Services.AddCascadingAuthenticationState();
Isso permite que qualquer componente receba Task<AuthenticationState> utilizando o atributo CascadingParameter
Com tudo isso configurado podemos utilizar o atributo `Authorize` nas páginas que necessitamos autenticação:
@attribute [Authorize]
Por exemplo, atualize o arquivo Components/Page/Home:
@page "/"
@using System.Security.Claims
@attribute [Authorize]
<PageTitle>User Claims</PageTitle>
<h1>User Claims</h1>
@if (claims.Count() > 0)
{
<ul>
@foreach (var claim in claims)
{
<li><b>@claim.Type:</b> @claim.Value</li>
}
</ul>
}
@code {
private IEnumerable<Claim> claims = Enumerable.Empty<Claim>();
[CascadingParameter]
private Task<AuthenticationState>? AuthState { get; set; }
protected override async Task OnInitializedAsync()
{
if (AuthState == null)
{
return;
}
var authState = await AuthState;
claims = authState.User.Claims;
}
}
Quando acessar a home será redirecionado para o SSO do provider e então redirecionado de volta com as claims do usuário logado
Agora estamos prontos para criar alguns componentes para exibir o nome do usuário logado:
dotnet new razorcomponent -o Components/Shared -n LoginDisplay
Com o seguinte conteúdo:
<AuthorizeView>
<Authorized>
Olá, @context?.User?.Identity?.Name!
</Authorized>
</AuthorizeView>
O componente AuthorizeView permite controlar o que é exibido de acordo com o estado atual da autenticação
Atualize o arquivo para exibir o nome do usuário no header:
<FluentHeader>
BlazorAuthDemo
<FluentSpacer />
<LoginDisplay /> // <- Aqui estamos
// adicionando nosso componente recem criado
</FluentHeader>
Adicione os seguintes namespace no arquivo Components/_Imports.razor para não precisar adicionar explicitamente em cada componente
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using BlazorAuthDemo.Components.Shared
LogOut
Vamos adicionar a funcionalidade de logout atualizando nosso arquivo LoginDisplay.razor:
<AuthorizeView>
<Authorized>
Hello, @context?.User?.Identity?.Name!
<form method="post" action="authentication/logout">
<AntiforgeryToken />
<input type="hidden" name="returnUrl" value="/signed-out" />
<FluentButton Type="ButtonType.Submit" Appearance="Appearance.Lightweight">Log out</FluentButton>
</form>
</Authorized>
</AuthorizeView>
Agora exibiremos o botão Log out que fará ralizará um form post para um endpoint específico que será registrado no Program.cs:
using Microsoft.FluentUI.AspNetCore.Components;
using BlazorAuthDemo.Components;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.AspNetCore.Authentication;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddFluentUIComponents();
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddOpenIdConnect(options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Authority = "https://pla-identity-server.dev.hbsa.com.br/realms/hbsa-dev/";
options.ClientId = "abc-powerapps-ondemand-web";
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
// Aqui precisamos configurar como o fluxo irá realizar o bind da propriedade Name da identidade
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "given_name";
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProviderForSignOut = async (context) =>
{
context.ProtocolMessage.IdTokenHint = await context.HttpContext.GetTokenAsync(OpenIdConnectResponseType.IdToken);
}
};
})
.AddCookie()
;
builder.Services.AddCascadingAuthenticationState();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapGroup("/authentication").MapLoginAndLogout(); // <- Aqui
app.Run();
O método MapLoginAndLogout é a seguinte implementação:
internal static class LoginLogoutEndpointRouteBuilderExtensions
{
internal static IEndpointConventionBuilder MapLoginAndLogout(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("");
group.MapGet("/login", (string? returnUrl) => TypedResults.Challenge(GetAuthProperties(returnUrl)))
.AllowAnonymous();
// Sign out of the Cookie and OIDC handlers. If you do not sign out with the OIDC handler,
// the user will automatically be signed back in the next time they visit a page that requires authentication
// without being able to choose another account.
group.MapPost("/logout", ([FromForm] string? returnUrl) => TypedResults.SignOut(GetAuthProperties(returnUrl),
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));
return group;
}
private static AuthenticationProperties GetAuthProperties(string? returnUrl)
{
// TODO: Use HttpContext.Request.PathBase instead.
const string pathBase = "/";
// Prevent open redirects.
if (string.IsNullOrEmpty(returnUrl))
{
returnUrl = pathBase;
}
else if (!Uri.IsWellFormedUriString(returnUrl, UriKind.Relative))
{
returnUrl = new Uri(returnUrl, UriKind.Absolute).PathAndQuery;
}
else if (returnUrl[0] != '/')
{
returnUrl = $"{pathBase}{returnUrl}";
}
return new AuthenticationProperties { RedirectUri = returnUrl };
}
}
Vamos criar a página que será utilizada após o logout:
dotnet new razorcomponent -o Components/Pages -n SignedOut
Com o seguinte conteúdo:
@page "/signed-out"
<h1>Log out feito com sucesso!</h1>
@code {
}
Quando executamos a aplicação temos o seguinte comportamento:
O menu ainda está visível mesmo quando não tem usuário logado, então vamos usar novamente o componente AuthorizeView para exibir o menu somente quando houver usuário. Atualize o arquivo Components/Layout/NavMenu.razor:
<AuthorizeView>
<Authorized>
<div class="navmenu">
<input type="checkbox" title="Menu expand/collapse toggle" id="navmenu-toggle" class="navmenu-icon" />
<label for="navmenu-toggle" class="navmenu-icon"><FluentIcon Value="@(new Icons.Regular.Size20.Navigation())" Color="Color.Fill" /></label>
<nav class="sitenav" aria-labelledby="main-menu" onclick="document.getElementById('navmenu-toggle').click();">
<FluentNavMenu Id="main-menu" Collapsible="true" Width="250" Title="Navigation menu" @bind-Expanded="expanded">
<FluentNavLink Href="/" Match="NavLinkMatch.All" Icon="@(new Icons.Regular.Size20.Home())" IconColor="Color.Accent">Home</FluentNavLink>
<FluentNavLink Href="counter" Icon="@(new Icons.Regular.Size20.NumberSymbolSquare())" IconColor="Color.Accent">Counter</FluentNavLink>
<FluentNavLink Href="weather" Icon="@(new Icons.Regular.Size20.WeatherPartlyCloudyDay())" IconColor="Color.Accent">Weather</FluentNavLink>
</FluentNavMenu>
</nav>
</div>
</Authorized>
</AuthorizeView>
@code {
private bool expanded = true;
}
Vamos agora atualizar novamente o LoginDisplay para ter um link de login quando não houver usuário logado:
<AuthorizeView>
<Authorized>
Hello, @context?.User?.Identity?.Name!
<form method="post" action="authentication/logout">
<AntiforgeryToken />
<input type="hidden" name="returnUrl" value="/signed-out" />
<FluentButton Type="ButtonType.Submit" Appearance="Appearance.Lightweight">Log out</FluentButton>
</form>
</Authorized>
<NotAuthorized>
<FluentNavLink Href="/authentication/login" IconColor="Color.Accent">Log in</FluentNavLink>
</NotAuthorized>
</AuthorizeView>
Isso resolveu minha necessidade e acredito que esse caso de uso é bem comum ;)
Obrigado e até a próxima!