Blazor WebApp OIDC Authentication

Alef Carlos
6 min readMay 25, 2024

--

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:

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

Exemplo do projeto padrão sendo executado

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
Ao acessar novamente a aplicação vemos o nome do usuário

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!

--

--

Alef Carlos

Desenvolvedor por paixão, arquiteto de software por profissão.