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
Cookiesserve para lembrar o resultado do Google entre o retorno e a emissão do código, e é apagado logo depois comSignOutAsync. - 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.