Programação Por Equipe DevBras

Capa do artigo: Client secret do OAuth no bundle do front: como achar e levar para o backend

Client secret do OAuth no bundle do front: como achar e levar para o backend

Descubra se o segredo do OAuth vazou no JavaScript, mova a troca do código para o servidor com state conferido e troque o segredo sem derrubar o login.

O login com Google do painel funciona há seis meses. Um dia alguém abre a aba Rede do navegador, clica em "Entrar" e vê um POST para o endpoint de token do Google com client_secret no corpo, saindo do próprio navegador. O guia de "aplicativo de servidor web" foi seguido, só que dentro do React.

A resposta curta: se o client secret aparece em qualquer arquivo que o navegador baixa, ele é público. A troca do authorization code pelo token tem que acontecer no backend, que lê o segredo de variável de ambiente, confere o state e devolve ao navegador só um cookie de sessão. Depois de mover, gere um segredo novo, porque o antigo já está em cache, em proxy e em histórico de build.

Por que o segredo no front é público

Tudo que vai para o bundle é texto que o navegador recebe. Minificar não esconde: a string continua lá, inteira. Variáveis de build como VITE_, NEXT_PUBLIC_ ou REACT_APP_ existem justamente para serem injetadas no JavaScript entregue ao cliente. Colocar o secret numa delas é publicá-lo.

Com o segredo em mãos, alguém pode trocar codes em nome do seu client, montar uma página de phishing que se apresenta como o seu app na tela de consentimento e gastar a sua cota. O fluxo de servidor web parte do princípio de que existe um lugar que o usuário não enxerga. Se esse lugar é o navegador, o modelo inteiro cai.

Se a sua aplicação é uma SPA sem backend próprio, o caminho é outro: cliente público com PKCE e sem secret, como em login no Vue com Authorization Code e PKCE. Este guia é para quem tem servidor e implementou o fluxo no lugar errado. O conceito geral está em OAuth no servidor web; aqui o foco é o diagnóstico e a migração.

Como saber se o seu segredo vazou

Gere o build de produção e procure o valor nos arquivos de saída. Não procure o nome da variável, procure o conteúdo: é ele que importa.

npm run build
# troque pelo início do seu secret real; nunca cole o valor inteiro em histórico compartilhado
grep -rl "INICIO_DO_SEGREDO" dist/ build/ .next/ 2>/dev/null

Se aparecer qualquer arquivo, vazou. Confira também o código-fonte por chamadas diretas ao endpoint de token (oauth2.googleapis.com/token) feitas por fetch no front, e o histórico do Git, porque o segredo pode ter saído do código e continuar nos commits antigos. Para isso, leia segredo no Git: troque a chave antes de limpar o histórico.

Como mover a troca do código para o backend

O exemplo: o painel da Loja Exemplo, com front em React e backend em Node.js com Express, atendendo em https://painel.lojaexemplo.com.br.

  1. Cadastre o redirect do backend no console do Google: https://painel.lojaexemplo.com.br/auth/google/callback. HTTPS, caminho exato, sem barra a mais.
  2. Tire o secret do front e coloque em variável de ambiente do servidor (GOOGLE_CLIENT_SECRET), nunca com prefixo público.
  3. Crie a rota de início, que gera um state aleatório, guarda na sessão e redireciona para o Google.
  4. Crie a rota de callback, que confere o state, troca o code no servidor e cria a sessão.
  5. Mude o botão do front para um link simples para /auth/google. O front não vê code nem token.
  6. Gere um novo secret no console, publique no servidor e só então desative o antigo.

O backend em Express

import crypto from 'node:crypto';
import express from 'express';
import session from 'express-session';

const app = express();
app.set('trust proxy', 1);
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 8 * 60 * 60 * 1000 }
}));

const REDIRECT_URI = 'https://painel.lojaexemplo.com.br/auth/google/callback';

app.get('/auth/google', (req, res) => {
  const state = crypto.randomBytes(32).toString('hex');
  req.session.oauthState = state;
  const params = new URLSearchParams({
    client_id: process.env.GOOGLE_CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: 'code',
    scope: 'openid email profile',
    state
  });
  res.redirect('https://accounts.google.com/o/oauth2/v2/auth?' + params);
});

app.get('/auth/google/callback', async (req, res) => {
  const { code, state, error } = req.query;
  const esperado = req.session.oauthState;
  delete req.session.oauthState;

  if (error) return res.redirect('/login?erro=cancelado');
  if (!code || !state || !esperado || state !== esperado) {
    return res.status(400).send('Requisição de login inválida.');
  }

  try {
    const resposta = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        code,
        client_id: process.env.GOOGLE_CLIENT_ID,
        client_secret: process.env.GOOGLE_CLIENT_SECRET,
        redirect_uri: REDIRECT_URI,
        grant_type: 'authorization_code'
      }),
      signal: AbortSignal.timeout(10000)
    });
    if (!resposta.ok) {
      console.error('Troca de code falhou', resposta.status);
      return res.redirect('/login?erro=falha');
    }
    const tokens = await resposta.json();
    const usuario = await validarIdToken(tokens.id_token);

    req.session.regenerate((err) => {
      if (err) return res.redirect('/login?erro=falha');
      req.session.usuarioId = usuario.sub;
      res.redirect('/');
    });
  } catch (err) {
    console.error('Erro no callback do Google', err.name);
    res.redirect('/login?erro=falha');
  }
});

Pontos que não se negocia: o state é usado uma vez e apagado; a sessão é regenerada depois do login, para evitar fixação de sessão; o log registra o status, nunca o corpo da resposta, que tem os tokens. A função validarIdToken precisa conferir assinatura, emissor, audiência e expiração com uma biblioteca oficial; o passo a passo está em conferir o token do Google no servidor.

O que devolver ao navegador

Só o cookie de sessão, httpOnly e secure. Se você precisa chamar APIs do Google em nome da pessoa, o access token e o refresh token ficam no servidor, associados à sessão, e o front pede ao seu backend. Devolver o refresh token ao JavaScript recria o problema que você acabou de resolver. Os detalhes do cookie estão em cookie HttpOnly.

Trocar o segredo sem derrubar o login

O console do Google permite gerenciar os segredos de um client. Gere o novo, atualize a variável no servidor, faça o deploy, confirme que logins novos funcionam e só então desative o antigo. Na ordem inversa, você derruba o login entre um passo e outro. O padrão geral está em trocar segredo sem derrubar o sistema.

Erros comuns

  • Esconder o secret em variável com prefixo público. O build injeta o valor no JavaScript e qualquer pessoa lê na aba Fontes.
  • Mover a troca para o backend e não trocar o segredo. O valor antigo continua válido e já está em caches, CDNs e históricos de build.
  • Não conferir o state. Um atacante pode fazer a vítima concluir o login com a conta dele, e os dados dela vão parar numa conta que ele controla.
  • Usar redirect com curinga ou HTTP. O code pode ser entregue a uma URL que você não controla ou trafegar em claro.
  • Reaproveitar o client do app móvel. Os tipos de client têm regras diferentes; misturar faz um vazamento num lado comprometer o outro.
  • Logar a resposta inteira do endpoint de token. O log passa a guardar access, refresh e ID token de todo mundo que entrou.

Checklist

  • O grep pelo início do secret no build de produção não encontra nada.
  • Nenhum fetch do front chama o endpoint de token.
  • O secret vive só em variável de ambiente ou cofre do servidor.
  • O redirect cadastrado é HTTPS e aponta para o backend.
  • O callback rejeita requisição sem state ou com state diferente.
  • O navegador recebe só cookie de sessão httpOnly, sem tokens no corpo.
  • O segredo antigo foi desativado depois do deploy do novo.

Rode agora o build de produção do seu front e procure o início do seu client secret nos arquivos gerados: ele aparece em algum arquivo que o navegador baixa?

Avalie este artigo

Referência: https://developers.google.com/identity/protocols/oauth2/web-server?hl=pt-br