Programação Por Equipe DevBras

Capa do artigo: Login no app .NET MAUI passo a passo: WebAuthenticator, PKCE e token guardado

Login no app .NET MAUI passo a passo: WebAuthenticator, PKCE e token guardado

Implemente o login de um app MAUI pelo navegador do sistema: gere PKCE e state, registre o retorno no Android e no iOS, troque o código por token e guarde no SecureStorage.

O app de vendas precisa de login com a conta corporativa. A primeira versão abriu um WebView com a página do provedor dentro do app, e o provedor começou a bloquear com "este navegador não é seguro". A resposta curta: em .NET MAUI, o login vai pelo WebAuthenticator, que abre o navegador do sistema, e o app recebe de volta só um código, que troca por token usando PKCE. Nenhum segredo de cliente no app, nenhuma senha digitada em tela sua.

Por que o WebView é recusado está explicado em o autenticador do MAUI não é uma WebView sua, e as regras de redirect do OAuth nativo em OAuth no aplicativo nativo. Este guia é a implementação, do começo ao fim, para o app Vendas, com client id app-vendas e retorno br.exemplo.vendas://callback.

Como o fluxo funciona

  1. O app gera dois valores aleatórios: o code_verifier (fica guardado em memória) e o state.
  2. Calcula o code_challenge, que é o SHA-256 do verifier em Base64 URL.
  3. Abre o navegador do sistema na página de autorização do provedor, mandando o challenge e o state.
  4. A pessoa entra no domínio real do provedor, com gerenciador de senhas e segundo fator funcionando.
  5. O provedor redireciona para br.exemplo.vendas://callback?code=...&state=..., e o sistema entrega esse endereço ao app.
  6. O app confere o state e troca o código por token, mandando o verifier original.

O PKCE resolve um problema específico do celular: outro app pode registrar o mesmo esquema de URL e receber o código. Sem o verifier, que nunca saiu do seu app, esse código não vale nada. O state impede que um retorno forjado, iniciado por outra pessoa, seja aceito como se fosse do seu login.

Como gerar PKCE e state

using System.Security.Cryptography;
using System.Text;

public static class Pkce
{
    public static string Aleatorio(int bytes = 32) =>
        Base64Url(RandomNumberGenerator.GetBytes(bytes));

    public static string Desafio(string verifier) =>
        Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));

    static string Base64Url(byte[] dados) =>
        Convert.ToBase64String(dados).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}

Use RandomNumberGenerator, não Random. Um verifier previsível anula o PKCE.

Como chamar o WebAuthenticator e trocar o código

using System.Net.Http.Json;

public record Tokens(string access_token, string? refresh_token, int expires_in);

public class Login
{
    const string Retorno = "br.exemplo.vendas://callback";
    readonly HttpClient http = new();

    public async Task<bool> EntrarAsync()
    {
        var verifier = Pkce.Aleatorio();
        var state = Pkce.Aleatorio(16);

        var url = "https://login.exemplo.com/authorize"
            + "?response_type=code"
            + "&client_id=app-vendas"
            + "&redirect_uri=" + Uri.EscapeDataString(Retorno)
            + "&scope=" + Uri.EscapeDataString("openid profile api-pedidos")
            + "&code_challenge=" + Pkce.Desafio(verifier)
            + "&code_challenge_method=S256"
            + "&state=" + state;

        WebAuthenticatorResult resultado;
        try
        {
            resultado = await WebAuthenticator.Default.AuthenticateAsync(
                new Uri(url), new Uri(Retorno));
        }
        catch (TaskCanceledException)
        {
            return false; // a pessoa fechou o navegador
        }

        if (!resultado.Properties.TryGetValue("state", out var voltou) || voltou != state)
            throw new InvalidOperationException("state não confere");
        var code = resultado.Properties["code"];

        var resposta = await http.PostAsync("https://login.exemplo.com/token",
            new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"] = "authorization_code",
                ["code"] = code,
                ["redirect_uri"] = Retorno,
                ["client_id"] = "app-vendas",
                ["code_verifier"] = verifier
            }));
        resposta.EnsureSuccessStatusCode();

        var tokens = await resposta.Content.ReadFromJsonAsync<Tokens>()
            ?? throw new InvalidOperationException("resposta vazia");
        await SecureStorage.Default.SetAsync("access_token", tokens.access_token);
        if (tokens.refresh_token is not null)
            await SecureStorage.Default.SetAsync("refresh_token", tokens.refresh_token);
        return true;
    }
}

Os endereços /authorize e /token são fictícios; os do seu provedor estão no documento de descoberta dele (/.well-known/openid-configuration). Repare no que não aparece: client_secret. No provedor, o app-vendas precisa estar cadastrado como cliente público, com PKCE e com exatamente esse redirect na lista.

Como registrar o retorno em cada plataforma

O sistema só entrega br.exemplo.vendas://callback ao app se o app disser que atende esse esquema. Use um esquema em formato de domínio invertido, que é muito menos provável de colidir com outro app do que myapp://.

No Android, crie uma Activity de retorno em Platforms/Android:

using Android.App;
using Android.Content;
using Android.Content.PM;

[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
[IntentFilter(new[] { Intent.ActionView },
    Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
    DataScheme = "br.exemplo.vendas")]
public class RetornoDoLogin : Microsoft.Maui.Authentication.WebAuthenticatorCallbackActivity
{
}

A documentação do MAUI também pede, para Android 11 em diante, uma entrada queries no manifesto para que o app enxergue o serviço de Custom Tabs do navegador:

<queries>
  <intent>
    <action android:name="android.support.customtabs.action.CustomTabsService" />
  </intent>
</queries>

No iOS, registre o esquema em CFBundleURLTypes no Info.plist, conforme a página de autenticação do MAUI. No Windows, confira o suporte atual na mesma documentação antes de prometer o recurso.

Depois do login

O token está no SecureStorage. Toda chamada à API leva esse token, e a API valida assinatura, emissor, audiência e prazo. Se o app usa Blazor Hybrid, o próximo passo é avisar a interface de quem entrou, como mostra Blazor Hybrid com login. No "sair", apague os dois tokens e, se o provedor oferecer, chame o endpoint de revogação.

O access token costuma durar minutos ou poucas horas. Quando a API responder 401, use o refresh token no mesmo endpoint /token, com grant_type=refresh_token, e grave o par novo. Se a renovação também falhar, apague tudo e chame EntrarAsync de novo. Não peça a senha em tela própria como plano B: o caminho de volta é sempre o navegador.

Como testar o fluxo antes de publicar

Quatro testes pegam quase todos os problemas. Primeiro, o caminho feliz num aparelho real, não só no emulador: o navegador abre, você entra e o app volta sozinho para a tela inicial. Segundo, feche o navegador no meio do login: o app precisa voltar à tela de entrada sem travar e sem mensagem de erro técnico. Terceiro, altere o state de propósito num build de depuração: o login tem de ser recusado. Quarto, desinstale e reinstale o app. No iOS, itens do Keychain podem sobreviver à desinstalação; se você não quer que a conta anterior volte sozinha, grave em Preferences uma marca de "primeira execução" e, quando ela não existir, chame SecureStorage.Default.RemoveAll() antes de tudo.

Erros comuns

  • Login em WebView com o formulário do provedor. Provedores grandes bloqueiam, e a pessoa não tem como confirmar que está no domínio verdadeiro.
  • Client secret no app. Qualquer um extrai do pacote e se passa pelo seu cliente.
  • Ignorar o state. Um retorno forjado é aceito e a pessoa entra na conta de outra, sem perceber.
  • Esquema genérico como myapp://. Colide com outro app instalado, e o código vai parar no lugar errado. Com PKCE ele não serve sozinho, mas o login falha.
  • Redirect diferente entre o app e o provedor. Uma barra no fim ou uma letra maiúscula, e o provedor recusa com "redirect_uri mismatch".
  • Token em Preferences. Vira texto em arquivo de configuração, legível em backup ou aparelho com root.

Checklist

  • Cliente cadastrado como público, com PKCE e um único redirect.
  • Verifier e state gerados com RandomNumberGenerator a cada login.
  • State conferido antes de usar o código.
  • Activity de retorno no Android com o esquema certo; esquema registrado no iOS.
  • Nenhum client_secret no projeto.
  • Tokens no SecureStorage e apagados no "sair".
  • Cancelamento do navegador tratado sem mensagem de erro.

Abra o login no seu app agora e olhe o topo da tela: aparece a barra do navegador com o domínio do provedor? Se aparece o visual do seu app sem endereço nenhum, você ainda está num WebView. E a pergunta para conferir no painel do provedor: quantas URLs de redirect estão cadastradas para esse cliente, e todas são suas?

Avalie este artigo

Referência: https://learn.microsoft.com/dotnet/maui/platform-integration/communication/authentication