Programação Por Equipe DevBras

Capa do artigo: WebAuthenticator no MAUI com backend ASP.NET Core

WebAuthenticator no MAUI com backend ASP.NET Core

Login do .NET MAUI via backend ASP.NET Core sem expor o token do Google: código de uso único com PKCE, retorno em lista fechada e fluxo testado com curl.

A documentação do WebAuthenticator no .NET MAUI sugere um backend ASP.NET Core que faz o login com o provedor e, no fim, redireciona para o esquema do app. Sobre o que vai nesse redirecionamento, ela dá duas opções e deixa a escolha com você: devolver o access_token do provedor nos parâmetros da URL de retorno, ou criar uma identidade no seu servidor e devolver um token seu. O controlador de exemplo da página é um esqueleto com comentários. Este post implementa a segunda opção em .NET 10: o backend devolve ao app um código de uso único, amarrado a um desafio PKCE gerado pelo app, e só entrega o token da sua API a quem prova que tem o verifier.

Se o seu app fala direto com o provedor, sem backend no meio, o caminho é outro: cliente público com PKCE, descrito passo a passo em Login no app .NET MAUI passo a passo. Aqui o cenário é o da documentação, com o segredo do Google guardado no servidor.

Por que o login não acontece numa WebView do app

O WebAuthenticator abre o navegador do sistema (Custom Tabs no Android, sessão de autenticação web no iOS) em vez de desenhar a página do provedor dentro do app. Não é preferência estética. A política de OAuth do Google diz que o desenvolvedor não deve direcionar um pedido de autorização para um navegador embutido sob seu controle, e a RFC 8252, que é a boa prática de OAuth para apps nativos, pede o navegador externo pelo mesmo motivo: numa WebView o app consegue ler o que a pessoa digita, e a pessoa não tem barra de endereço para conferir que está no domínio do provedor.

O preço do navegador externo é o vai-e-volta. O login termina numa URL com o esquema do app, o sistema operacional entrega essa URL a quem registrou o esquema, e o MAUI completa a Task. É nesse retorno que mora o problema de segurança deste post: no Android, outro app pode registrar o mesmo esquema personalizado. Tudo que passa por ali precisa ser inútil para quem não é o seu app.

Por que não devolver o token do provedor pela URL

A opção mais curta, devolver o access_token do Google na URL de retorno, tem dois problemas. Se um app impostor recebe esse redirecionamento, recebe um token do Google válido, que funciona sem nenhuma outra prova. E o token do Google não é o que a sua API deveria aceitar: ele foi emitido para as APIs do Google, com escopos do Google.

A segunda opção, do jeito que está aqui, muda duas coisas. O que passa pelo esquema é um código de 60 segundos, de uso único. E esse código só vale junto com o code_verifier, um valor aleatório que nasceu no app e nunca saiu dele; o backend conhece apenas o SHA-256 dele, que o app mandou na primeira URL. É a mesma ideia do PKCE do OAuth, agora entre o app e o seu backend.

O fluxo completo, do jeito que foi testado:

O backend em .NET 10

O projeto é um dotnet new web com o pacote Microsoft.AspNetCore.Authentication.Google (10.0.14 no teste). A configuração do cliente do Google segue o guia de login com Google no ASP.NET Core; o redirect cadastrado no console do Google é o do backend (/signin-google), nunca o esquema do app. O token próprio vem do esquema BearerToken, que já existe no ASP.NET Core e emite token de acesso e de renovação.

using System.Collections.Concurrent;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.BearerToken;
using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

var auth = builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(o => o.ExpireTimeSpan = TimeSpan.FromMinutes(5))
    .AddGoogle("Google", o =>
    {
        o.ClientId = builder.Configuration["Google:ClientId"]!;
        o.ClientSecret = builder.Configuration["Google:ClientSecret"]!;
    })
    .AddBearerToken(o => o.BearerTokenExpiration = TimeSpan.FromMinutes(15));

List<string> provedores = ["Google"];
if (builder.Environment.IsDevelopment())
{
    auth.AddScheme<AuthenticationSchemeOptions, LoginDeTeste>("Teste", null);
    provedores.Add("Teste");
}
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

string[] retornosAceitos = ["br.com.agendaja://callback"];
var pendentes = new ConcurrentDictionary<string, Pendente>();

// 1. O app abre esta URL no WebAuthenticator.
app.MapGet("/mobileauth/{provedor}", async (string provedor, string? retorno,
    string? desafio, HttpContext ctx) =>
{
    if (!provedores.Contains(provedor))
        return Results.NotFound();
    if (retorno is null || !retornosAceitos.Contains(retorno, StringComparer.Ordinal))
        return Results.BadRequest("retorno não autorizado");
    if (desafio is not { Length: 43 })
        return Results.BadRequest("desafio PKCE ausente");

    var login = await ctx.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    if (!login.Succeeded)
        return Results.Challenge(
            new AuthenticationProperties { RedirectUri = ctx.Request.Path + ctx.Request.QueryString },
            [provedor]);

    // 2. Voltou do provedor: gera um código de uso único, curto, ligado ao desafio do app.
    var codigo = Base64Url(RandomNumberGenerator.GetBytes(32));
    pendentes[codigo] = new Pendente(
        login.Principal!.FindFirstValue(ClaimTypes.NameIdentifier)!,
        login.Principal!.FindFirstValue(ClaimTypes.Email),
        desafio,
        DateTimeOffset.UtcNow.AddSeconds(60));
    await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    return Results.Redirect($"{retorno}#code={codigo}");
});

// 3. O app troca o código pelo token da SUA API, provando que tem o verifier.
app.MapPost("/mobileauth/troca", (Troca t) =>
{
    if (t.Code is null || !pendentes.TryRemove(t.Code, out var p) || p.Expira < DateTimeOffset.UtcNow)
        return Results.BadRequest(new { erro = "código inválido, vencido ou já usado" });

    var calculado = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(t.Verifier ?? "")));
    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.ASCII.GetBytes(calculado), Encoding.ASCII.GetBytes(p.Desafio)))
        return Results.BadRequest(new { erro = "verifier não confere" });

    var id = new ClaimsIdentity(
        [new Claim(ClaimTypes.NameIdentifier, p.UsuarioId), new Claim(ClaimTypes.Email, p.Email ?? "")],
        BearerTokenDefaults.AuthenticationScheme);
    return Results.SignIn(new ClaimsPrincipal(id), authenticationScheme: BearerTokenDefaults.AuthenticationScheme);
});

app.MapGet("/api/eu", (ClaimsPrincipal u) => new
{
    id = u.FindFirstValue(ClaimTypes.NameIdentifier),
    email = u.FindFirstValue(ClaimTypes.Email),
}).RequireAuthorization(p => p
    .AddAuthenticationSchemes(BearerTokenDefaults.AuthenticationScheme)
    .RequireAuthenticatedUser());

app.Run();

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

record Pendente(string UsuarioId, string? Email, string Desafio, DateTimeOffset Expira);
record Troca(string? Code, string? Verifier);

// Só em Development: simula a volta do provedor para testar o fluxo sem conta real.
class LoginDeTeste(Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> o,
    ILoggerFactory l, System.Text.Encodings.Web.UrlEncoder e)
    : AuthenticationHandler<AuthenticationSchemeOptions>(o, l, e)
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync() =>
        Task.FromResult(AuthenticateResult.NoResult());

    protected override async Task HandleChallengeAsync(AuthenticationProperties props)
    {
        var id = new ClaimsIdentity(
            [new Claim(ClaimTypes.NameIdentifier, "teste-001"), new Claim(ClaimTypes.Email, "ana.teste@agendaja.test")],
            "Teste");
        await Context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(id));
        Context.Response.Redirect(props.RedirectUri!);
    }
}

Quatro decisões no código merecem atenção:

  • Lista fechada de retornos. O backend só redireciona para br.com.agendaja://callback, comparado por igualdade exata. Sem isso, o endpoint vira um redirecionador aberto que entrega códigos para qualquer esquema que alguém pedir.
  • TryRemove antes de conferir o verifier. O código sai do dicionário na primeira tentativa, certa ou errada. Duas chamadas simultâneas com o mesmo código não passam, porque só uma consegue remover.
  • Cookie só durante o login. O cookie do esquema Cookies serve para lembrar o resultado do Google entre o retorno e a emissão do código, e é apagado logo depois com SignOutAsync.
  • O provedor Teste só existe em Development. Ele simula a volta do Google para que o fluxo possa ser exercitado sem conta real, e não é registrado em produção.

O dicionário em memória serve para uma instância. Com mais de uma, os códigos precisam de um armazenamento compartilhado com remoção atômica, como o GETDEL do Redis com expiração de 60 segundos, e as chaves de Data Protection, que assinam o token do BearerToken, precisam ser as mesmas em todas as instâncias. Em instância única, uma limpeza periódica dos códigos vencidos evita que o dicionário cresça com logins abandonados.

Testando o fluxo inteiro com curl

Com o servidor rodando em Development e credenciais falsas do Google, este script percorre o caminho que o app faria, incluindo as tentativas que precisam falhar:

#!/usr/bin/env bash
# Testa o fluxo do backend com o provedor "Teste" (só existe em Development).
set -u
B=http://localhost:5080
R='br.com.agendaja://callback'
VERIFIER=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=')
DESAFIO=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
J=$(mktemp)
# lê o Location cru (o -w %{redirect_url} do curl normaliza e acrescenta uma barra)
loc() { curl -s -c "$J" -b "$J" -o /dev/null -D - "$@" | tr -d '\r' | sed -n 's/^[Ll]ocation: //p'; }

echo "== retorno fora da lista"
curl -s -o /dev/null -w '%{http_code}\n' -G "$B/mobileauth/Teste" \
  --data-urlencode "retorno=br.com.outroapp://callback" --data-urlencode "desafio=$DESAFIO"

echo "== Google sem sessão: vai para a tela do Google"
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -G "$B/mobileauth/Google" \
  --data-urlencode "retorno=$R" --data-urlencode "desafio=$DESAFIO" | cut -c1-120

echo "== fluxo completo com o provedor de teste"
VOLTA=$(curl -s -c "$J" -b "$J" -o /dev/null -w '%{redirect_url}' -G "$B/mobileauth/Teste" \
  --data-urlencode "retorno=$R" --data-urlencode "desafio=$DESAFIO")
FINAL=$(loc "$VOLTA")
echo "$FINAL" | cut -c1-60
CODE=${FINAL#*#code=}

echo "== troca com verifier errado"
curl -s -X POST "$B/mobileauth/troca" -H 'Content-Type: application/json' \
  -d "{\"code\":\"$CODE\",\"verifier\":\"outro-verifier\"}"; echo
echo "== mesmo código de novo, agora com o verifier certo"
curl -s -X POST "$B/mobileauth/troca" -H 'Content-Type: application/json' \
  -d "{\"code\":\"$CODE\",\"verifier\":\"$VERIFIER\"}"; echo

echo "== fluxo novo, troca certa"
VOLTA=$(curl -s -c "$J" -b "$J" -o /dev/null -w '%{redirect_url}' -G "$B/mobileauth/Teste" \
  --data-urlencode "retorno=$R" --data-urlencode "desafio=$DESAFIO")
FINAL=$(loc "$VOLTA")
CODE=${FINAL#*#code=}
RESP=$(curl -s -X POST "$B/mobileauth/troca" -H 'Content-Type: application/json' \
  -d "{\"code\":\"$CODE\",\"verifier\":\"$VERIFIER\"}")
echo "$RESP" | python3 -c 'import json,sys; d=json.load(sys.stdin); print({k:(v if k in ("tokenType","expiresIn") else str(v)[:12]+"...") for k,v in d.items()})'
TOKEN=$(echo "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])')
echo "== API com o token"
curl -s "$B/api/eu" -H "Authorization: Bearer $TOKEN"; echo
echo "== API sem token"
curl -s -o /dev/null -w '%{http_code}\n' "$B/api/eu"

Saída no box, em 28/10/2026:

== retorno fora da lista
400
== Google sem sessão: vai para a tela do Google
302 https://accounts.google.com/o/oauth2/v2/auth?client_id=123-app.apps.googleusercontent.com&scope=openid%20profile%20e
== fluxo completo com o provedor de teste
br.com.agendaja://callback#code=nbJuNVgx-3-aofDspsSwVD5oGjMB
== troca com verifier errado
{"erro":"verifier não confere"}
== mesmo código de novo, agora com o verifier certo
{"erro":"código inválido, vencido ou já usado"}
== fluxo novo, troca certa
{'tokenType': 'Bearer', 'accessToken': 'CfDJ8Ou9gWLL...', 'expiresIn': 900, 'refreshToken': 'CfDJ8Ou9gWLL...'}
== API com o token
{"id":"teste-001","email":"ana.teste@agendaja.test"}
== API sem token
401

Cada linha confirma uma garantia. O retorno fora da lista recebe 400. O pedido com o Google, sem sessão, vai para accounts.google.com com o redirect do backend, e o handler do ASP.NET Core já manda code_challenge_method=S256 e state nessa etapa, sem configuração extra. O código volta pelo esquema, não serve com o verifier errado e não serve de novo depois da primeira tentativa. Só a troca certa entrega o token, de 900 segundos, que a API aceita e sem o qual responde 401.

Um detalhe que apareceu no teste: o %{redirect_url} do curl normaliza a URL e mostra br.com.agendaja://callback/#code=..., com uma barra que o servidor não mandou. O cabeçalho Location cru não tem a barra, e é ele que o sistema operacional recebe. Por isso o script lê o cabeçalho direto.

O lado do app

No app, a chamada gera o par verifier e desafio, abre a URL do backend e faz a troca. O endereço api.agendaja.com.br é do app fictício deste post; use o do seu backend:

using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;

public record TokensApi(string AccessToken, string RefreshToken, int ExpiresIn);

public class Entrada
{
    const string Api = "https://api.agendaja.com.br";
    const string Retorno = "br.com.agendaja://callback";
    readonly HttpClient http = new();

    public async Task<bool> EntrarAsync(string provedor = "Google")
    {
        var verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
        var desafio = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
        try
        {
            var r = await WebAuthenticator.Default.AuthenticateAsync(new WebAuthenticatorOptions
            {
                Url = new Uri($"{Api}/mobileauth/{provedor}?retorno={Uri.EscapeDataString(Retorno)}&desafio={desafio}"),
                CallbackUrl = new Uri(Retorno),
                PrefersEphemeralWebBrowserSession = true,
            });
            if (!r.Properties.TryGetValue("code", out var code))
                return false;

            var resp = await http.PostAsJsonAsync($"{Api}/mobileauth/troca", new { code, verifier });
            if (!resp.IsSuccessStatusCode)
                return false;

            var t = (await resp.Content.ReadFromJsonAsync<TokensApi>())!;
            await SecureStorage.Default.SetAsync("access_token", t.AccessToken);
            await SecureStorage.Default.SetAsync("refresh_token", t.RefreshToken);
            return true;
        }
        catch (TaskCanceledException)
        {
            return false; // a pessoa fechou o navegador; volta para a tela de entrada sem erro
        }
    }

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

O verifier fica só na memória durante o login. O PrefersEphemeralWebBrowserSession vale no iOS e abre uma sessão sem cookies compartilhados, o que importa em aparelho de uso coletivo. Os tokens vão para o SecureStorage, que usa o armazenamento protegido de cada plataforma. Para a interface do app saber quem entrou depois disso, Blazor Hybrid e quem entra continua o assunto.

Registrar o esquema em cada plataforma

Sem o registro, o navegador não tem para onde entregar o retorno e a pessoa fica numa página de erro dizendo que o endereço não pode ser aberto, enquanto a Task continua esperando. No Android, a documentação pede uma activity que herda de WebAuthenticatorCallbackActivity, com o esquema no filtro de intent:

// Platforms/Android/WebAuthenticationCallbackActivity.cs
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.com.agendaja")]
public class WebAuthenticationCallbackActivity
    : Microsoft.Maui.Authentication.WebAuthenticatorCallbackActivity
{
}

Com alvo Android 11 (API 30) ou superior, o manifesto também precisa declarar a consulta ao serviço de Custom Tabs:

<!-- Platforms/Android/AndroidManifest.xml, dentro de <manifest> -->
<queries>
  <intent>
    <action android:name="android.support.customtabs.action.CustomTabsService" />
  </intent>
</queries>

No iOS, o esquema entra em CFBundleURLTypes no Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>br.com.agendaja</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>br.com.agendaja</string>
    </array>
  </dict>
</array>

O esquema em formato de domínio invertido diminui a chance de colisão acidental com outro app, mas não impede um impostor deliberado. Quem resolve isso é o desenho do backend acima. O post OAuth no app móvel: como impedir que outro aplicativo receba o seu código testa esse ataque com um app impostor.

Windows e Apple

Em outubro de 2026, a página oficial ainda diz que o WebAuthenticator não está funcionando no Windows e aponta a issue 2702 do repositório do MAUI. Se o app também roda no desktop, planeje outro fluxo ali antes de prometer o mesmo login. No iOS, a mesma página lembra a regra de revisão da Apple: app que oferece login social precisa oferecer também o Entrar com a Apple, que no iOS 13 ou superior usa o AppleSignInAuthenticator nativo e devolve um IdToken em vez de AccessToken. O backend deste post recebe esse provedor como mais um nome na lista provedores, com o pacote do provedor Apple configurado no ASP.NET Core.

Avalie este artigo

Referência: https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/communication/authentication?view=net-maui-8.0&tabs=android