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
- 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. - Coloque o client ID e o client secret em variáveis de ambiente do servidor, nunca no repositório nem no bundle do front.
- Na rota
/login, gere ostatecom um gerador criptográfico, guarde na sessão e redirecione para o provedor. - Na rota
/auth/callback, compare ostaterecebido com o da sessão e apague o da sessão na hora, para ele não servir de novo. - Troque o código por tokens numa chamada servidor a servidor, com o segredo.
- Valide o ID token: assinatura, emissor, audiência igual ao seu client ID e validade.
- Regenere a sessão antes de gravar o usuário, para que um ID de sessão anterior ao login não continue valendo.
- 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. statefixo 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
greppelo client secret no bundle do front não encontra nada. - A lista de redirects tem URLs exatas, uma por ambiente.
- O callback recusa um
stateausente, 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,SecureeSameSite=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?
Referência: https://developers.google.com/identity/protocols/oauth2/web-server