Login no Vue com Authorization Code e PKCE: sem client secret no navegador
Implemente login OpenID Connect numa SPA com o fluxo de código e PKCE, sem segredo no JavaScript, com redirect fechado e API que valida o token de verdade.
O time colocou o client_secret numa variável de ambiente do Vite, achando que isso o protegia. O build embutiu o valor no JavaScript final, e qualquer pessoa que abrisse o DevTools via o segredo. A resposta curta: aplicação que roda no navegador é cliente público e não guarda segredo nenhum. O fluxo certo é Authorization Code com PKCE: o app gera um valor aleatório a cada login, manda só um resumo dele ao provedor e, na volta, prova que é o mesmo app mostrando o valor original.
Esse é o fluxo recomendado hoje para SPAs, apps móveis e qualquer cliente que não consegue esconder credencial. O fluxo implícito, que devolvia o token direto na URL, não é mais recomendado. Se o seu projeto ainda usa response_type=token, está na hora de migrar.
Por que o navegador não pode ter segredo
Tudo que vai para o navegador é público: HTML, JavaScript, variáveis embutidas no build. Ofuscar não muda isso. Um client_secret numa SPA é apenas uma string conhecida, e quem a tem pode se passar pelo seu app junto ao provedor de identidade.
Sem segredo, surge outro problema. No fluxo de código, o provedor devolve um código de autorização na URL de retorno. Esse código pode vazar: por extensão maliciosa, por log, por um app que registrou o mesmo esquema de URL no celular. Quem pega o código poderia trocá-lo por tokens.
O PKCE (Proof Key for Code Exchange, RFC 7636) fecha essa porta. Antes de sair, o app gera um code_verifier aleatório e calcula o code_challenge, que é o SHA-256 dele em base64url. Só o desafio vai na ida. Na troca do código, o app manda o verificador original. O provedor calcula o SHA-256, compara com o desafio guardado e só então entrega o token. Quem roubou o código não tem o verificador, e o hash não permite descobrir o verificador a partir do desafio.
Como implementar o fluxo com PKCE
- Cadastre o app no provedor como cliente público (sem segredo), com o tipo de concessão Authorization Code e PKCE obrigatório, se o provedor oferecer essa opção.
- Registre as URLs de retorno exatas, uma a uma. Nada de curinga.
- No clique em entrar, gere o
code_verifier, ocode_challengee umstatealeatório. Guarde verificador e state emsessionStorage. - Redirecione para o endpoint de autorização com
code_challenge_method=S256. - Na página de retorno, confira se o
staterecebido é igual ao guardado. Se não for, aborte. - Troque o código por tokens no endpoint de token, mandando o
code_verifier. Apague verificador e state em seguida. - Guarde o token de acesso em memória e envie no cabeçalho
Authorizationdas chamadas à API.
Na prática, use uma biblioteca mantida, como oidc-client-ts, que faz tudo isso e ainda lê a configuração do provedor. Mas vale entender o que ela faz. Este é o núcleo, em JavaScript puro do navegador:
function base64url(bytes) {
let texto = '';
bytes.forEach(function (b) { texto += String.fromCharCode(b); });
return btoa(texto).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function aleatorio(tamanho) {
return base64url(crypto.getRandomValues(new Uint8Array(tamanho)));
}
async function desafioDe(verificador) {
const dados = new TextEncoder().encode(verificador);
const hash = await crypto.subtle.digest('SHA-256', dados);
return base64url(new Uint8Array(hash));
}
async function entrar() {
const verificador = aleatorio(32);
const state = aleatorio(16);
sessionStorage.setItem('pkce_verificador', verificador);
sessionStorage.setItem('pkce_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'app-vue-exemplo',
redirect_uri: 'https://app.exemplo.com.br/retorno',
scope: 'openid profile',
state: state,
code_challenge: await desafioDe(verificador),
code_challenge_method: 'S256'
});
window.location.assign('https://login.exemplo.com.br/authorize?' + params.toString());
}
async function concluirRetorno() {
const url = new URL(window.location.href);
const state = sessionStorage.getItem('pkce_state');
if (!state || url.searchParams.get('state') !== state) {
throw new Error('state não confere');
}
const corpo = new URLSearchParams({
grant_type: 'authorization_code',
code: url.searchParams.get('code'),
redirect_uri: 'https://app.exemplo.com.br/retorno',
client_id: 'app-vue-exemplo',
code_verifier: sessionStorage.getItem('pkce_verificador')
});
sessionStorage.removeItem('pkce_state');
sessionStorage.removeItem('pkce_verificador');
const resposta = await fetch('https://login.exemplo.com.br/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: corpo
});
if (!resposta.ok) throw new Error('troca do código falhou');
return resposta.json();
}
Os endereços /authorize e /token acima são fictícios. Os reais estão no documento de descoberta do seu provedor, em /.well-known/openid-configuration. Verificador de 32 bytes vira 43 caracteres em base64url, que é o mínimo exigido pela especificação.
A lista fechada de retorno
O provedor só entrega o código para uma URL que você cadastrou. Se a lista aceita curinga ou qualquer caminho de um domínio, um parâmetro manipulado pode mandar o código para uma página que o atacante controla. O mesmo raciocínio vale para o parâmetro de retorno do seu próprio app depois do login, assunto de redirect que obedece ao parâmetro next.
O que a API faz com o token
O Vue mostrar o nome da pessoa não prova nada para a API. Cada chamada leva o token de acesso, e a API confere assinatura com as chaves públicas do provedor, emissor, audiência e prazo, como em JWT que expira. Audiência é o campo que mais se esquece: sem ela, um token emitido para outro app do mesmo provedor passa na sua API.
Uma alternativa mais robusta, recomendada para apps que lidam com dado sensível, é o padrão BFF (backend for frontend): um backend seu faz o fluxo como cliente confidencial e o navegador só recebe um cookie de sessão HttpOnly. O token nunca chega ao JavaScript. Dá mais trabalho de infraestrutura e elimina a pergunta de onde guardar o token.
Para sair, limpe o estado local e, se o provedor suportar, redirecione para o end_session_endpoint com id_token_hint e um post_logout_redirect_uri cadastrado. Sem isso, a sessão continua viva no provedor e o próximo clique em entrar volta logado, sem pedir senha.
Erros comuns
- Client secret no front. O segredo fica público no bundle e qualquer um se passa pelo seu app.
- Não conferir o state. O retorno aceita um código iniciado por outra pessoa, e a vítima acaba logada na conta do atacante.
- Usar
plainem vez deS256. O desafio passa a ser o próprio verificador, e quem vê a ida já tem o que precisa para a volta. - Curinga nas URLs de retorno. O código pode ser entregue a uma página que não é sua.
- API que confia no front. Sem validar assinatura e audiência, qualquer token, ou nenhum, passa.
- Token em
localStoragesem necessidade. Uma falha de injeção de script leva o acesso inteiro.
Checklist
- Nenhum
client_secretaparece no código ou no bundle do front. - O app está cadastrado como cliente público, com PKCE.
- O desafio usa
S256. - O
stateé gerado por login e conferido no retorno. - As URLs de retorno estão listadas uma a uma, sem curinga.
- A API valida assinatura, emissor, audiência e prazo do token.
- O logout encerra também a sessão no provedor.
Rode uma busca por client_secret no repositório do front e no bundle publicado. Se aparecer, o app ainda se comporta como cliente confidencial no lugar errado. E a pergunta para a tela de configuração do provedor: as URLs de retorno estão listadas uma a uma, ou existe um asterisco ali?