Programação Por Equipe DevBras

Capa do artigo: OAuth no servidor web: a rota de callback que confere o state e guarda o segredo

OAuth no servidor web: a rota de callback que confere o state e guarda o segredo

Monte a rota de retorno do OAuth no backend: state conferido, código trocado com o client secret no servidor e sessão nova. Exemplo em Node.js.

O login com Google funciona em todos os testes. Aí alguém manda para um colega um link que termina em /auth/callback?code=...&state=..., o colega clica e passa a usar o sistema logado na conta de outra pessoa. A falha estava numa linha que ninguém escreveu: a rota de retorno não conferia se o state era dela. A resposta curta: num site com backend, toda a parte sensível do OAuth acontece no servidor. O navegador só vai ao provedor e volta; a rota de callback confere o state guardado na sessão, troca o código usando o client secret que mora numa variável de ambiente, valida o ID token e cria uma sessão nova.

O exemplo deste guia é o painel Frota Leve, em Node.js com Express, publicado em https://painel.frotaleve.com.br, que usa login do Google para os gestores.

Por que a troca do código mora no servidor

Um site com backend pode ser um cliente confidencial: ele tem onde guardar um segredo que o usuário nunca vê. O provedor entrega o código de autorização no redirect, e só quem tem o client secret consegue trocá-lo por tokens. Se essa troca acontece no JavaScript do navegador, o segredo precisa estar no front, e qualquer pessoa o lê no DevTools. O site vira um cliente público fingindo ser confidencial.

Se o seu front é uma SPA sem backend, o caminho é outro: Authorization Code com PKCE e nenhum segredo, como em login no Vue com PKCE. Este guia é para quem tem servidor e deve usá-lo. Para o argumento de onde o segredo fica, veja também o segredo de OAuth fica no servidor; aqui o assunto é a rota de callback, linha por linha.

O que o state protege

Sem o state, um atacante inicia o login com a conta dele, para antes de usar o código e manda o link de callback para a vítima. A vítima clica, o seu servidor troca o código e cria uma sessão, só que da conta do atacante. Tudo o que a vítima cadastrar a partir daí fica na conta dele. Esse é o CSRF de login.

O state é um valor aleatório que o servidor gera, guarda na sessão do navegador que iniciou o login e manda ao provedor. O provedor o devolve intacto. Se o valor que chega não é o que está na sessão deste navegador, o login não foi iniciado aqui e a rota recusa.

Como montar o fluxo passo a passo

  1. Cadastre o cliente como aplicativo web no console do provedor e liste a URL de retorno exata: https://painel.frotaleve.com.br/auth/callback. Sem curinga, sem domínio de teste esquecido.
  2. Coloque o client ID e o client secret em variáveis de ambiente do servidor, nunca no repositório nem no bundle do front.
  3. Na rota /login, gere o state com um gerador criptográfico, guarde na sessão e redirecione para o provedor.
  4. Na rota /auth/callback, compare o state recebido com o da sessão e apague o da sessão na hora, para ele não servir de novo.
  5. Troque o código por tokens numa chamada servidor a servidor, com o segredo.
  6. Valide o ID token: assinatura, emissor, audiência igual ao seu client ID e validade.
  7. Regenere a sessão antes de gravar o usuário, para que um ID de sessão anterior ao login não continue valendo.
  8. Descarte o código e só guarde tokens do provedor se você realmente for chamar APIs dele depois.

O código da rota de callback

O exemplo usa express-session para a sessão e google-auth-library para validar o ID token. O fetch é o nativo do Node 18 em diante.

const crypto = require('node:crypto');
const express = require('express');
const session = require('express-session');
const { OAuth2Client } = require('google-auth-library');

const CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const REDIRECT_URI = 'https://painel.frotaleve.com.br/auth/callback';
const google = new OAuth2Client(CLIENT_ID);

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' }
}));

app.get('/login', (req, res) => {
  const state = crypto.randomBytes(32).toString('base64url');
  req.session.oauthState = state;
  const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
  url.searchParams.set('client_id', CLIENT_ID);
  url.searchParams.set('redirect_uri', REDIRECT_URI);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('scope', 'openid email');
  url.searchParams.set('state', state);
  res.redirect(url.toString());
});

function mesmoState(recebido, esperado) {
  if (typeof recebido !== 'string' || typeof esperado !== 'string') return false;
  const a = Buffer.from(recebido);
  const b = Buffer.from(esperado);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.get('/auth/callback', async (req, res) => {
  try {
    const esperado = req.session.oauthState;
    delete req.session.oauthState;
    if (!mesmoState(req.query.state, esperado)) {
      return res.status(400).send('Login não iniciado neste navegador.');
    }
    if (typeof req.query.code !== 'string') {
      return res.status(400).send('Retorno sem código.');
    }

    const resp = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        code: req.query.code,
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        redirect_uri: REDIRECT_URI,
        grant_type: 'authorization_code'
      })
    });
    if (!resp.ok) return res.status(502).send('Falha ao trocar o código.');
    const tokens = await resp.json();

    const ticket = await google.verifyIdToken({ idToken: tokens.id_token, audience: CLIENT_ID });
    const perfil = ticket.getPayload();

    req.session.regenerate((err) => {
      if (err) return res.status(500).send('Erro de sessão.');
      req.session.usuario = { sub: perfil.sub, email: perfil.email };
      res.redirect('/');
    });
  } catch (e) {
    console.error('callback oauth', e.message);
    res.status(500).send('Não foi possível concluir o login.');
  }
});

app.listen(3000);

Repare em três detalhes. O state sai da sessão antes de qualquer verificação, então não pode ser reaproveitado. A comparação usa timingSafeEqual depois de checar o tamanho, porque essa função lança erro com buffers de tamanhos diferentes. E o log registra a mensagem do erro, nunca o código nem os tokens, como manda log sem senha.

Por que o cookie é Lax e não Strict

O retorno do provedor é uma navegação vinda de outro site. Com SameSite=Strict, o navegador não envia o cookie de sessão nessa navegação, a rota não acha o state e todo login falha. Lax envia o cookie em navegações de topo com GET, que é exatamente o caso do callback, e continua bloqueando o envio em POSTs vindos de fora.

PKCE também no servidor

As recomendações atuais de segurança do OAuth sugerem usar PKCE mesmo em clientes confidenciais. Ele protege contra a injeção de um código roubado em outra sessão. Se o seu provedor aceita, gere o code_verifier junto com o state, guarde os dois na sessão e envie o verifier na troca. Custa poucas linhas.

Erros comuns

  • Client secret no JavaScript do front. Qualquer visitante copia; alguém pode se passar pelo seu aplicativo diante do provedor.
  • Callback que ignora o state. Abre o CSRF de login: a vítima trabalha logada na conta do atacante sem perceber.
  • state fixo ou guardado em cookie comum sem relação com a sessão. O atacante prevê ou planta o valor, e a proteção vira enfeite.
  • Usar o e-mail do perfil sem validar o ID token. Sem conferir audiência e assinatura, um token emitido para outro aplicativo é aceito como se fosse seu.
  • Manter o mesmo ID de sessão antes e depois do login. Quem conseguiu fixar o ID antes passa a ter uma sessão autenticada.
  • Redirect com curinga ou domínio de homologação na lista de produção. O código pode ser entregue a um endereço que você não controla.

Checklist

  • O client secret está só em variável de ambiente do servidor.
  • Um grep pelo client secret no bundle do front não encontra nada.
  • A lista de redirects tem URLs exatas, uma por ambiente.
  • O callback recusa um state ausente, diferente ou já usado.
  • O ID token é validado com audiência igual ao seu client ID.
  • A sessão é regenerada depois do login.
  • O cookie de sessão é HttpOnly, Secure e SameSite=Lax, como em cookie HttpOnly.

Faça o teste agora: inicie um login, copie a URL de callback antes de concluir e abra-a numa janela anônima. A sua rota recusa um state que ela não gerou para aquele navegador?

Avalie este artigo

Referência: https://developers.google.com/identity/protocols/oauth2/web-server