Programação Por Equipe DevBras

Capa do artigo: Blazor Hybrid não herda o login do site: como autenticar o app de verdade

Blazor Hybrid não herda o login do site: como autenticar o app de verdade

O app híbrido não enxerga o cookie do navegador. Entenda por que, como autenticar o app com token guardado no cofre do aparelho e como fazer o sair apagar o acesso de verdade.

A equipe reaproveitou os componentes Razor do portal web num app .NET MAUI com Blazor Hybrid. No site, o técnico de campo entra uma vez e fica logado por dias. No app, abre a tela de chamados e vê "não autorizado". A primeira ideia de alguém foi gravar usuário e senha num arquivo de preferências para "logar sozinho". A segunda foi copiar o cookie do site para dentro do WebView.

A resposta curta: o app híbrido é outro cliente, com outro armazenamento, e não herda a sessão do navegador. Ele precisa do próprio fluxo de login, feito pelo navegador do sistema com OIDC e PKCE, e o token que volta fica no cofre seguro do aparelho, nunca a senha. Os componentes Razor são os mesmos; quem diz quem está logado agora é o código nativo do app.

Por que o app não vê o cookie do site

No Blazor Hybrid, os componentes rodam dentro do processo .NET do app e desenham a interface num WebView embutido: WebView2 no Windows, o WebView do Android e o WKWebView no iOS e no macOS. Esse WebView tem o próprio armazenamento de cookies, separado do Chrome, do Edge ou do Safari que a pessoa usa no dia a dia. O cookie de sessão que o portal gravou no navegador simplesmente não existe ali.

Há uma segunda diferença, mais importante. No Blazor Server ou no WebAssembly, a autenticação é da aplicação web: o servidor emite o cookie e o navegador o reenvia. No Hybrid, a documentação da Microsoft trata a autenticação como responsabilidade do app nativo. É o código .NET que obtém um token, guarda, renova e informa aos componentes quem é o usuário por meio de um AuthenticationStateProvider. O WebView é só a tela.

Por isso copiar o cookie é uma gambiarra frágil: ele expira, depende de domínio e de atributos como SameSite, e obriga o app a manipular uma credencial do navegador que não foi emitida para ele. Gravar a senha é pior: ela fica no disco do aparelho, sobrevive à troca de senha no servidor só até a próxima falha e pode vazar em backup.

O desenho certo: o app como cliente OIDC

O app de chamados passa a ser um cliente registrado no provedor de identidade (Entra ID, Keycloak, Duende, Auth0, o que a empresa usar), do tipo cliente público, sem segredo embutido. O fluxo fica assim:

  1. O técnico toca em "Entrar". O app abre o navegador do sistema na página de login do provedor, com PKCE.
  2. Depois do login, o provedor redireciona para um esquema registrado pelo app, como chamados://callback.
  3. O app troca o código pelos tokens: um access token de vida curta e, se a política permitir, um refresh token.
  4. O refresh token vai para o SecureStorage, que usa o Keychain no iOS e armazenamento protegido pelo sistema nas outras plataformas.
  5. Um AuthenticationStateProvider próprio informa aos componentes quem entrou. As chamadas à API levam o access token no cabeçalho Authorization.
  6. Sair apaga o token local e, quando o provedor oferece, revoga o refresh token no servidor.

Não escreva a troca de código e o PKCE na mão. Use uma biblioteca de cliente nativo mantida, como a MSAL.NET para Entra ID ou a IdentityModel.OidcClient para provedores OIDC em geral. O motivo de usar o navegador do sistema, e não um formulário de login dentro do WebView, está explicado no guia de login no app MAUI, e o de não embutir segredo está em app nativo não carrega o segredo do cliente.

O provedor de estado que os componentes enxergam

O exemplo abaixo mostra a parte que costuma faltar: a ponte entre o token que o app obteve e o [Authorize] e o AuthorizeView dos componentes. O ILoginNativo representa a sua integração com a biblioteca OIDC escolhida.

using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;

public interface ILoginNativo
{
    Task<ResultadoLogin?> EntrarAsync();
    Task<ResultadoLogin?> RenovarAsync(string refreshToken);
    Task RevogarAsync(string refreshToken);
}

public record ResultadoLogin(string AccessToken, string? RefreshToken, ClaimsPrincipal Usuario);

public class EstadoDeLogin : AuthenticationStateProvider
{
    private const string ChaveRefresh = "chamados_refresh_token";
    private static readonly ClaimsPrincipal Anonimo = new(new ClaimsIdentity());

    private readonly ILoginNativo _login;
    private ClaimsPrincipal _usuario = Anonimo;

    public string? AccessToken { get; private set; }

    public EstadoDeLogin(ILoginNativo login) => _login = login;

    public override Task<AuthenticationState> GetAuthenticationStateAsync()
        => Task.FromResult(new AuthenticationState(_usuario));

    public async Task RestaurarAsync()
    {
        var refresh = await SecureStorage.Default.GetAsync(ChaveRefresh);
        if (refresh is null) return;

        var r = await _login.RenovarAsync(refresh);
        if (r is null)
        {
            SecureStorage.Default.Remove(ChaveRefresh);
            return;
        }
        await AplicarAsync(r);
    }

    public async Task EntrarAsync()
    {
        var r = await _login.EntrarAsync();
        if (r is not null) await AplicarAsync(r);
    }

    public async Task SairAsync()
    {
        var refresh = await SecureStorage.Default.GetAsync(ChaveRefresh);
        SecureStorage.Default.Remove(ChaveRefresh);
        AccessToken = null;
        _usuario = Anonimo;
        NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());

        if (refresh is not null)
        {
            try { await _login.RevogarAsync(refresh); }
            catch { /* sem rede: o token local já foi apagado */ }
        }
    }

    private async Task AplicarAsync(ResultadoLogin r)
    {
        if (r.RefreshToken is not null)
            await SecureStorage.Default.SetAsync(ChaveRefresh, r.RefreshToken);
        AccessToken = r.AccessToken;
        _usuario = r.Usuario;
        NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
    }
}

No MauiProgram.cs, registre o provedor uma vez e exponha a mesma instância como AuthenticationStateProvider:

builder.Services.AddAuthorizationCore();
builder.Services.AddSingleton<ILoginNativo, LoginOidc>();
builder.Services.AddSingleton<EstadoDeLogin>();
builder.Services.AddSingleton<AuthenticationStateProvider>(
    sp => sp.GetRequiredService<EstadoDeLogin>());

Repare na ordem do SairAsync: primeiro apaga o token do disco e limpa o estado, depois tenta revogar no servidor. Se o aparelho estiver sem rede, o técnico ainda sai de verdade daquele aparelho. A revogação é o segundo passo, para que um token copiado antes também deixe de valer.

A API continua conferindo tudo

O AuthorizeView esconde botão; quem protege dado é a API. Cada chamada do app chega com o access token, e o backend valida assinatura, emissor, audiência e validade, exatamente como faria com qualquer outro cliente. Um app híbrido pode ser descompilado, e o código dos componentes está no pacote instalado. O guia sobre a identidade que o servidor ainda confere no Blazor Hybrid aprofunda esse lado.

Erros comuns

  • Esperar que o login do site valha no app. O WebView tem armazenamento próprio, e a pessoa cai na tela de não autorizado sem entender por quê.
  • Guardar a senha em Preferences ou arquivo. A senha fica legível no aparelho e em backups, e a troca de senha no servidor não tira o acesso de quem copiou o arquivo.
  • Login com formulário próprio dentro do WebView. O app passa a ver a senha do provedor, o MFA e o gerenciador de senhas do sistema deixam de funcionar, e alguns provedores bloqueiam esse uso.
  • Embutir o segredo do cliente no app. Qualquer um extrai o segredo do pacote; app nativo é cliente público e usa PKCE.
  • Sair que só troca de tela. O refresh token continua no disco e o próximo que abrir o app entra com a conta anterior.
  • Testar só no emulador já autenticado. O bug de primeiro acesso, de token expirado e de aparelho sem rede só aparece em instalação limpa.

Checklist

  • O app é registrado no provedor como cliente público, com redirect para esquema próprio.
  • O login abre o navegador do sistema, com PKCE, via biblioteca mantida.
  • Nenhuma senha é gravada no aparelho; só o refresh token, no SecureStorage.
  • Um AuthenticationStateProvider próprio alimenta [Authorize] e AuthorizeView.
  • A API valida o access token em toda chamada, sem confiar no que a tela esconde.
  • Sair apaga o token local antes de qualquer chamada de rede e depois tenta revogar.
  • Você testou instalação limpa, token vencido, conta desativada e aparelho sem rede.

Faça o teste agora no aparelho de homologação: entre no app, toque em sair, feche e abra de novo. A tela pede login, ou o refresh token continua no disco esperando o próximo que pegar o celular?

Avalie este artigo

Referência: https://learn.microsoft.com/pt-br/aspnet/core/blazor/hybrid/security/