Login do Google que trava dentro do app: por que a WebView não serve para OAuth
O login funciona no site e para no app híbrido com tela em branco ou disallowed_useragent. Entenda por que o provedor recusa a WebView e como abrir o navegador do sistema com retorno para o app.
O app da loja foi feito em Capacitor, reaproveitando o site. No navegador do computador, o botão "Entrar com Google" funciona. No celular, dentro do app, a pessoa toca no botão e vê uma página do Google com Erro 403: disallowed_useragent, ou uma tela em branco que nunca volta. Ela acha que errou a senha. A resposta curta: o Google e outros provedores recusam login em WebView embutida de propósito, e o conserto não é disfarçar a WebView, é abrir o navegador do sistema e fazer o retorno chegar ao app por um endereço registrado.
Este guia é para quem tem app híbrido (Cordova, Capacitor, Ionic ou WebView própria) e herdou o login do site. O exemplo do começo ao fim é o app br.com.lojaexemplo.app, com login OAuth via Authorization Code e PKCE.
Por que a WebView não é o navegador
Uma WebView é um componente de navegador que o seu app controla. O app pode injetar JavaScript na página, ler o que é digitado e capturar cookies. Do ponto de vista do provedor de identidade, uma página de login aberta ali é uma página de senha dentro de um programa de terceiro que pode estar gravando tudo. Por isso a recomendação da própria especificação de OAuth para apps nativos (a RFC 8252) é usar o navegador externo ou uma aba do navegador do sistema, nunca a WebView do app.
Há também motivos práticos:
- A WebView não compartilha a sessão do navegador do aparelho. A pessoa já está logada no Google no Chrome do celular, mas dentro da WebView precisa digitar tudo de novo, inclusive o segundo fator.
- O redirect de volta para o site (
https://www.lojaexemplo.com.br/callback) não significa nada dentro do app. A WebView abre a página do site, e o app nunca recebe o código. - Cookies e storage da WebView seguem regras diferentes das do navegador, e mudam entre versões do sistema. O que funcionava em um aparelho falha em outro.
O mesmo erro aparece fora do seu app: quem abre o link da sua loja dentro do navegador interno de uma rede social também pode ver o bloqueio do Google, porque aquele navegador interno é uma WebView.
O fluxo certo: navegador do sistema e retorno pelo esquema do app
A forma recomendada tem quatro peças:
- Um cliente OAuth do tipo app no provedor, separado do cliente web. Muitos provedores pedem um client id por plataforma (Android, iOS), com o identificador do pacote. Não reaproveite o do site.
- Um endereço de retorno que o sistema entrega ao seu app. Pode ser um esquema próprio no formato de domínio invertido, como
br.com.lojaexemplo.app:/oauth/callback, ou um link HTTPS verificado (App Links no Android, Universal Links no iOS). - PKCE obrigatório. App não guarda segredo: qualquer um extrai o client secret do pacote. O
code_verifiergerado na hora substitui o segredo. - Abrir a URL de autorização no navegador do sistema (Custom Tabs no Android, sessão de autenticação do sistema no iOS) e escutar o retorno no app.
Exemplo com Capacitor
Com os plugins oficiais @capacitor/browser e @capacitor/app, o app abre a autorização fora da WebView e recebe a URL de volta quando o sistema a entrega:
import { Browser } from '@capacitor/browser';
import { App } from '@capacitor/app';
const AUTORIZACAO = 'https://id.provedor-exemplo.com/authorize';
const TOKEN = 'https://id.provedor-exemplo.com/token';
const CLIENT_ID = 'app-loja-android';
const REDIRECT = 'br.com.lojaexemplo.app:/oauth/callback';
function base64url(bytes) {
let texto = '';
bytes.forEach((b) => { texto += String.fromCharCode(b); });
return btoa(texto).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
}
async function gerarPkce() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return { verifier, challenge: base64url(new Uint8Array(hash)) };
}
export async function entrar() {
const { verifier, challenge } = await gerarPkce();
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem('pkce', JSON.stringify({ verifier, state }));
const url = new URL(AUTORIZACAO);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT);
url.searchParams.set('scope', 'openid profile');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
await Browser.open({ url: url.toString() });
}
App.addListener('appUrlOpen', async ({ url }) => {
if (!url.startsWith(REDIRECT)) return;
await Browser.close();
const retorno = new URL(url);
const salvo = JSON.parse(sessionStorage.getItem('pkce') || '{}');
sessionStorage.removeItem('pkce');
if (retorno.searchParams.get('state') !== salvo.state) {
throw new Error('state diferente: login descartado');
}
const resposta = await fetch(TOKEN, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: retorno.searchParams.get('code'),
redirect_uri: REDIRECT,
client_id: CLIENT_ID,
code_verifier: salvo.verifier
})
});
if (!resposta.ok) throw new Error('troca do código falhou: ' + resposta.status);
const tokens = await resposta.json();
// guarde os tokens no armazenamento seguro do aparelho, não no localStorage
});
Além do código, o esquema br.com.lojaexemplo.app precisa estar declarado no projeto nativo (intent filter no Android, URL Types no iOS), senão o sistema não sabe que a URL é do seu app. E o mesmo endereço precisa estar cadastrado no cliente do provedor. Para um app de produção, prefira uma biblioteca certificada de OAuth nativo, como as baseadas em AppAuth, que já resolvem PKCE, troca e renovação; o exemplo acima mostra o que ela faz por dentro.
E o Firebase
Se o login é pelo Firebase Authentication num app Cordova, siga a página oficial do Firebase para Cordova, que configura o redirect pelo navegador e o retorno ao app com plugins específicos. O princípio é o mesmo: a tela do Google abre fora da WebView.
Erros comuns
- Trocar o user agent da WebView para "parecer Chrome". Viola a política do provedor, pode ser bloqueado a qualquer momento e mantém o risco que o bloqueio quer evitar.
- Fazer um formulário próprio pedindo e-mail e senha do Google. Você vira o phishing que a pessoa aprendeu a evitar, e a conta com segundo fator nem funciona.
- Colocar o client secret no app. Qualquer pessoa extrai do pacote. Cliente de app é público e usa PKCE.
- Usar o client id do site no app. O provedor recusa o redirect do app, ou aceita com regras de cliente web que não fazem sentido no aparelho.
- Testar só no navegador do computador com
ionic serveou similar. Ali não há WebView nem esquema de app, e o problema só aparece no aparelho. - Guardar o token no
localStorageda WebView. Use o armazenamento seguro do sistema (Keystore, Keychain) por meio de um plugin.
Checklist
- A tela de login do provedor abre no navegador do sistema, não dentro da WebView.
- Existe um cliente OAuth do tipo app no provedor, separado do cliente web.
- O endereço de retorno do app está cadastrado no provedor e declarado no projeto Android e iOS.
- O fluxo usa PKCE com
S256e confere ostatena volta. - Nenhum client secret está no código do app.
- Os tokens ficam no armazenamento seguro do aparelho.
- O login foi testado num aparelho real, Android e iOS, com e sem sessão prévia no Google.
Os vizinhos deste guia aprofundam peças específicas: o retorno tem de ser o seu app, app nativo não carrega o segredo do cliente e, para quem está em .NET, login no app MAUI pelo navegador do sistema.
Abra o seu app num celular de teste e toque em entrar: a página do provedor aparece com a barra do navegador do sistema, ou dentro da mesma tela do app?
Referência: https://firebase.google.com/docs/auth/web/cordova