Programação Por Equipe DevBras

Capa do artigo: Login no Vue com Authorization Code e PKCE: sem client secret no navegador

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

  1. 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.
  2. Registre as URLs de retorno exatas, uma a uma. Nada de curinga.
  3. No clique em entrar, gere o code_verifier, o code_challenge e um state aleatório. Guarde verificador e state em sessionStorage.
  4. Redirecione para o endpoint de autorização com code_challenge_method=S256.
  5. Na página de retorno, confira se o state recebido é igual ao guardado. Se não for, aborte.
  6. Troque o código por tokens no endpoint de token, mandando o code_verifier. Apague verificador e state em seguida.
  7. Guarde o token de acesso em memória e envie no cabeçalho Authorization das 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 plain em vez de S256. 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 localStorage sem necessidade. Uma falha de injeção de script leva o acesso inteiro.

Checklist

  • Nenhum client_secret aparece 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?

Avalie este artigo

Referência: https://damienbod.com/2019/01/29/securing-a-vue-js-app-using-openid-connect-code-flow-with-pkce-and-identityserver4/