OAuth 2.0 é o protocolo de autorização que está por trás de praticamente todos os botões “Iniciar sessão com Google” ou “Entrar com GitHub” que utiliza todos os dias. Em 2026, a sua implementação segura, com PKCE (Proof Key for Code Exchange), deixou de ser opcional: o RFC 9126 da IETF recomenda PKCE para todos os clientes OAuth, incluindo aplicações confidenciais no servidor. O rascunho OAuth 2.1 elimina por completo o fluxo implícito e torna o PKCE obrigatório no fluxo de código de autorização.
Neste guia passo a passo, vai construir um servidor Express 5 completo em Node.js 22 LTS que implementa o fluxo Authorization Code com PKCE usando o GitHub como fornecedor. O projeto final inclui geração de PKCE nativa (sem bibliotecas externas para a criptografia), gestão de sessões, refresh automático de tokens, middleware de proteção de rotas e logout seguro. São 12 passos, cerca de 45 minutos de trabalho.
O que é OAuth 2.0 e Porque Substitui Passwords
OAuth 2.0, definido no RFC 6749, é um protocolo de autorização delegada. Em vez de partilhar a password com uma aplicação terceira, o utilizador concede à aplicação um token de acesso com escopo limitado e prazo de validade. A aplicação nunca vê as credenciais do utilizador.
O protocolo define quatro entidades principais:
- Resource Owner: o utilizador que detém os dados.
- Client: a aplicação que quer aceder aos dados (o seu servidor Node.js).
- Authorization Server: o servidor que autentica o utilizador e emite tokens (GitHub, Google, Keycloak).
- Resource Server: a API que protege os dados (por exemplo, a API do GitHub).
O fluxo mais seguro para aplicações web com servidor é o Authorization Code Flow com PKCE. O servidor redireciona o utilizador para o Authorization Server, que devolve um código de autorização de curta duração. O servidor troca esse código por tokens de acesso e refresh, sem que o browser veja os tokens finais. Este isolamento é a principal vantagem sobre o fluxo implícito (agora removido do OAuth 2.1), onde os tokens apareciam diretamente no URL do browser.
OAuth 2.0 resolve um problema real: sem ele, uma aplicação terceira precisaria da password do utilizador para aceder, por exemplo, aos seus contactos do Google. Com OAuth, o utilizador autoriza um acesso limitado (apenas leitura de contactos, por 30 dias) sem nunca partilhar a password. Se a aplicação terceira for comprometida, o atacante obtém apenas um token revogável, não a password.
PKCE: Porque é Obrigatório em 2026
PKCE (Proof Key for Code Exchange), definido no RFC 7636, resolve o ataque de interceção de código de autorização. Sem PKCE, um processo malicioso na mesma máquina que capte o redirect URI consegue trocar o código por tokens. Com PKCE, isso é impossível porque o Authorization Server exige prova criptográfica de que quem pede o token é o mesmo que iniciou o fluxo.
O mecanismo funciona assim:
- O cliente gera um code_verifier: uma string aleatória de alta entropia (43 a 128 caracteres, base64url).
- O cliente calcula o code_challenge:
BASE64URL(SHA256(code_verifier)). - O cliente envia o
code_challengeecode_challenge_method=S256no pedido de autorização. - O Authorization Server guarda o
code_challengeassociado ao código emitido. - Na troca do código, o cliente envia o
code_verifieroriginal. O servidor verifica seSHA256(code_verifier) == code_challenge.
Um atacante que intercete o código de autorização não tem o code_verifier, por isso não consegue obter tokens. O RFC 9126 recomenda SHA-256 (S256) como único método aceite; o método “plain” deve ser rejeitado pelos servidores modernos por oferecer proteção nula contra interceção. O OAuth 2.1, em fase de rascunho avançado, incorpora PKCE como requisito base para todos os clientes que usem o fluxo de código de autorização, incluindo clientes confidenciais com servidor.
Comparação de Fluxos OAuth 2.0
| Fluxo | Caso de Uso | PKCE | Estado em OAuth 2.1 |
|---|---|---|---|
| Authorization Code + PKCE | Aplicações web com servidor, SPAs, apps móveis | Obrigatório | Padrão recomendado |
| Client Credentials | Machine-to-machine sem utilizador | N/A | Mantido |
| Authorization Code (sem PKCE) | Aplicações web legadas | Recomendado pelo RFC 9126 | Descontinuado sem PKCE |
| Implicit Flow | SPAs antigas com tokens no URL | N/A | Removido |
| Resource Owner Password | Aplicações de confiança total | N/A | Removido |
| Device Authorization | Dispositivos sem browser (TV, CLI) | N/A | Mantido |
Pré-requisitos
Antes de começar, confirme que tem instalado e configurado:
- Node.js 22 LTS ou superior (execute
node --versionpara confirmar) - npm 10 ou superior (
npm --version) - Uma conta no GitHub gratuita, para criar a aplicação OAuth de teste
- Conhecimento básico de Express.js e middleware HTTP
- Familiaridade com sessões HTTP e cookies
- Terminal com acesso à internet
Pacotes npm utilizados neste tutorial:
| Pacote | Versão | Função |
|---|---|---|
| express | 5.2.1 | Framework web |
| express-session | 1.19.0 | Gestão de sessões no servidor |
| axios | 1.18.0 | Pedidos HTTP ao Authorization Server |
| dotenv | 17.4.2 | Variáveis de ambiente |
| crypto (built-in) | Node.js 22 | Geração de PKCE (randomBytes, SHA-256) |
Passo 1: Criar a Aplicação OAuth no GitHub
O GitHub serve como Authorization Server neste tutorial. As instruções aplicam-se a qualquer fornecedor OAuth 2.0 compatível (Google, Microsoft Entra ID, Keycloak), com ajuste dos endpoints e parâmetros específicos de cada fornecedor.
Siga estes passos no GitHub:
- Aceda ao GitHub.com, clique na sua foto de perfil e selecione Settings.
- No menu lateral, clique em Developer settings (em baixo).
- Selecione OAuth Apps e clique em New OAuth App.
- Preencha o formulário com estes valores exatos:
- Application name:
oauth2-pkce-tutorial - Homepage URL:
http://localhost:3000 - Authorization callback URL:
http://localhost:3000/auth/callback
Após criar a aplicação, o GitHub mostra o Client ID imediatamente. Para o Client Secret, clique em Generate a new client secret e copie o valor mostrado, pois não será apresentado novamente. Guarde ambos em local seguro: o GITHUB_CLIENT_ID e o GITHUB_CLIENT_SECRET.
Os endpoints do GitHub para OAuth 2.0 são:
- Autorização:
https://github.com/login/oauth/authorize - Token:
https://github.com/login/oauth/access_token - API do utilizador:
https://api.github.com/user
Consulte a documentação oficial do GitHub OAuth Apps para a lista completa de escopos disponíveis e limites de taxa da API.
Passo 2: Inicializar o Projeto Node.js
Crie a estrutura do projeto e instale as dependências com os comandos seguintes:
# Criar e entrar na pasta do projeto
mkdir oauth2-pkce-nodejs && cd oauth2-pkce-nodejs
# Inicializar package.json com valores padrão
npm init -y
# Instalar dependências de produção com versões exatas
npm install [email protected] [email protected] [email protected] [email protected]
# Criar estrutura de ficheiros do projeto
mkdir -p src
touch src/index.js src/pkce.js src/auth.js src/middleware.js .env .gitignore
Atualize o package.json para adicionar o script de desenvolvimento com hot-reload nativo do Node.js 22:
{
"name": "oauth2-pkce-nodejs",
"version": "1.0.0",
"description": "OAuth 2.0 Authorization Code Flow com PKCE em Node.js/Express",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"axios": "^1.18.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-session": "^1.19.0"
}
}
A estrutura final do projeto após todos os passos:
oauth2-pkce-nodejs/
├── src/
│ ├── index.js # Servidor Express principal e definição de rotas
│ ├── pkce.js # Geração de code_verifier e code_challenge (PKCE)
│ ├── auth.js # Lógica do fluxo OAuth (autorização e troca de código)
│ └── middleware.js # Middleware de proteção de rotas autenticadas
├── .env # Variáveis de ambiente (nunca incluir no controlo de versões)
├── .gitignore # Excluir .env e node_modules
└── package.json
Passo 3: Configurar Variáveis de Ambiente
Nunca coloque credenciais OAuth diretamente no código-fonte. Preencha o ficheiro .env com os valores obtidos no GitHub no Passo 1:
# .env
# Credenciais da aplicação OAuth no GitHub
GITHUB_CLIENT_ID=Ov23liXXXXXXXXXXXXXX
GITHUB_CLIENT_SECRET=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0
# URL base da aplicação (sem barra final)
APP_URL=http://localhost:3000
# URL de callback OAuth (tem de coincidir EXATAMENTE com o registo no GitHub)
REDIRECT_URI=http://localhost:3000/auth/callback
# Porta do servidor
PORT=3000
# Segredo da sessão (use 64+ bytes aleatórios em produção)
SESSION_SECRET=substitua_por_valor_aleatorio_seguro_em_producao
Adicione .env ao .gitignore imediatamente para evitar expor as credenciais num commit acidental:
# .gitignore
.env
node_modules/
*.log
.DS_Store
Gere um segredo de sessão seguro com o Node.js diretamente no terminal:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# Exemplo de saída:
# a3f8e2b1c9d4...128 caracteres hexadecimais
Passo 4: Implementar a Geração de PKCE
O módulo crypto nativo do Node.js 22 tem tudo o que precisa para PKCE: randomBytes para entropia criptograficamente segura e createHash para SHA-256. Nenhuma dependência externa é necessária para esta parte, o que reduz a superfície de ataque da cadeia de fornecimento.
Crie o ficheiro src/pkce.js:
// src/pkce.js
const crypto = require('crypto');
/**
* Gera um code_verifier de alta entropia.
* RFC 7636 exige 43-128 caracteres do alphabet [A-Z, a-z, 0-9, -, ., _, ~]
* base64url cobre este alphabet sem padding.
*/
function generateCodeVerifier() {
// 32 bytes = 256 bits de entropia, codificados em base64url = 43 caracteres
return crypto.randomBytes(32).toString('base64url');
}
/**
* Calcula o code_challenge a partir do code_verifier.
* Método S256 (obrigatório): BASE64URL(SHA256(ASCII(code_verifier)))
*/
function generateCodeChallenge(codeVerifier) {
return crypto
.createHash('sha256')
.update(codeVerifier, 'ascii')
.digest('base64url');
}
/**
* Gera um valor state aleatório para proteção CSRF.
* O state vincula o pedido de autorização à sessão do browser.
*/
function generateState() {
return crypto.randomBytes(16).toString('hex');
}
module.exports = { generateCodeVerifier, generateCodeChallenge, generateState };
A codificação base64url (suportada nativamente desde Node.js 14.18.0) difere do base64 padrão: substitui + por -, / por _ e remove o padding =. Estes caracteres são seguros em URLs sem necessidade de percent-encoding adicional, requisito obrigatório do RFC 7636 para os valores PKCE.
Para verificar que a geração funciona corretamente, teste no terminal:
node -e "
const { generateCodeVerifier, generateCodeChallenge } = require('./src/pkce');
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
console.log('code_verifier:', verifier);
console.log('code_verifier length:', verifier.length);
console.log('code_challenge:', challenge);
"
# Saída esperada (valores variam a cada execução):
# code_verifier: xK9mP2nQrT5uVwYz3aB6cD8eF1gH4iJ7
# code_verifier length: 43
# code_challenge: Xr4Kp9mN2qT5uVwYz3aB6cD8eF1gH4iJ
Passo 5: Construir o URL de Autorização e Iniciar o Fluxo
O URL de autorização é o ponto de entrada do fluxo OAuth. O servidor redireciona o utilizador para este URL, que inclui os parâmetros PKCE e o state anti-CSRF. Crie o ficheiro src/auth.js:
// src/auth.js
const axios = require('axios');
const { generateCodeVerifier, generateCodeChallenge, generateState } = require('./pkce');
const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize';
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const GITHUB_API_URL = 'https://api.github.com';
/**
* Inicia o fluxo OAuth: gera PKCE, guarda na sessão e redireciona.
*/
function startAuthFlow(req, res) {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Guardar na sessão do servidor (nunca expor ao browser)
req.session.codeVerifier = codeVerifier;
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID,
redirect_uri: process.env.REDIRECT_URI,
scope: 'read:user user:email',
response_type: 'code',
state: state,
// PKCE: enviar code_challenge para fornecedores que suportam RFC 7636
// (Google, Microsoft Entra ID, Keycloak suportam; GitHub suporte parcial)
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(`${GITHUB_AUTHORIZE_URL}?${params.toString()}`);
}
/**
* Trata o callback OAuth: valida state, troca código por tokens.
*/
async function handleCallback(req, res) {
const { code, state } = req.query;
// Validação 1: verificar parâmetros obrigatórios
if (!code || !state) {
return res.status(400).send('Parâmetros OAuth em falta na resposta do callback.');
}
// Validação 2: verificar state anti-CSRF (CRÍTICO)
if (state !== req.session.oauthState) {
return res.status(403).send('State inválido. Possível ataque CSRF detetado.');
}
// Validação 3: verificar que a sessão tem o code_verifier
const codeVerifier = req.session.codeVerifier;
if (!codeVerifier) {
return res.status(400).send('Sessão expirada. Inicie o login novamente.');
}
try {
// Trocar o código de autorização por tokens de acesso
const tokenResponse = await axios.post(
GITHUB_TOKEN_URL,
{
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code: code,
redirect_uri: process.env.REDIRECT_URI,
// Para fornecedores com suporte PKCE completo (Google, Keycloak):
// code_verifier: codeVerifier,
},
{
headers: { Accept: 'application/json' }, // Fundamental para GitHub
}
);
const { access_token, token_type, scope } = tokenResponse.data;
if (!access_token) {
throw new Error('Resposta sem access_token: ' + JSON.stringify(tokenResponse.data));
}
// Guardar token na sessão (server-side, não acessível ao JavaScript do browser)
req.session.accessToken = access_token;
req.session.tokenType = token_type;
req.session.tokenScope = scope;
// Limpar dados PKCE e state da sessão (já não são necessários)
delete req.session.codeVerifier;
delete req.session.oauthState;
// Obter dados do utilizador autenticado
const userResponse = await axios.get(`${GITHUB_API_URL}/user`, {
headers: { Authorization: `Bearer ${access_token}` },
});
req.session.user = {
id: userResponse.data.id,
login: userResponse.data.login,
name: userResponse.data.name,
email: userResponse.data.email,
avatar: userResponse.data.avatar_url,
};
// Forçar persistência da sessão antes do redirect
req.session.save((err) => {
if (err) return res.status(500).send('Erro ao guardar sessão.');
res.redirect('/dashboard');
});
} catch (error) {
console.error('Erro no callback OAuth:', error.response?.data || error.message);
res.status(500).send('Falha na autenticação OAuth. Tente novamente.');
}
}
module.exports = { startAuthFlow, handleCallback };
Passo 6: Criar o Middleware de Autenticação
O middleware verifica se o utilizador tem uma sessão válida antes de permitir acesso a rotas protegidas. Crie o ficheiro src/middleware.js:
// src/middleware.js
/**
* requireAuth: bloqueia rotas para utilizadores não autenticados.
* Redireciona para /auth/login em vez de devolver 401 (melhor UX para rotas web).
*/
function requireAuth(req, res, next) {
if (req.session && req.session.user && req.session.accessToken) {
return next();
}
// Guardar URL pretendida para redirecionar após login bem-sucedido
req.session.returnTo = req.originalUrl;
res.redirect('/auth/login');
}
/**
* requireNoAuth: impede utilizadores já autenticados de aceder ao login.
* Evita loops de redirect e states duplicados.
*/
function requireNoAuth(req, res, next) {
if (req.session && req.session.user) {
return res.redirect('/dashboard');
}
next();
}
/**
* attachUser: torna o utilizador disponível em res.locals para vistas.
* Usar como middleware global em todas as rotas.
*/
function attachUser(req, res, next) {
res.locals.user = req.session.user || null;
next();
}
module.exports = { requireAuth, requireNoAuth, attachUser };
Passo 7: Montar o Servidor Express Principal
O ficheiro src/index.js une todos os módulos e define as rotas da aplicação:
// src/index.js
require('dotenv').config(); // PRIMEIRO: carregar variáveis de ambiente
const express = require('express');
const session = require('express-session');
const { startAuthFlow, handleCallback } = require('./auth');
const { requireAuth, requireNoAuth, attachUser } = require('./middleware');
const app = express();
const PORT = process.env.PORT || 3000;
// Necessário se a app estiver atrás de proxy (Nginx, Cloudflare, etc.)
// app.set('trust proxy', 1);
// Configuração da sessão com opções de segurança
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Impede acesso ao cookie via JavaScript (proteção XSS)
secure: process.env.NODE_ENV === 'production', // HTTPS obrigatório em produção
sameSite: 'lax', // Proteção CSRF adicional (lax permite redirect OAuth)
maxAge: 24 * 60 * 60 * 1000, // 24 horas em milissegundos
},
name: 'oauth_sid', // Nome personalizado (não revela que usa express-session)
}));
// Middleware global
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(attachUser);
// Rota principal
app.get('/', (req, res) => {
if (req.session.user) return res.redirect('/dashboard');
res.send(`
OAuth 2.0 PKCE Demo
OAuth 2.0 com PKCE em Node.js
Demonstração do fluxo Authorization Code com PKCE usando GitHub.
Iniciar sessão com GitHub
`);
});
// Rotas OAuth
app.get('/auth/login', requireNoAuth, startAuthFlow);
app.get('/auth/callback', handleCallback);
// Rota protegida: dashboard
app.get('/dashboard', requireAuth, (req, res) => {
const { user } = req.session;
res.send(`
Dashboard
Bem-vindo, ${user.name || user.login}!
Username GitHub: @${user.login}
Email: ${user.email || 'não público'}
Escopos: ${req.session.tokenScope}
`);
});
// Rota protegida: dados JSON da sessão
app.get('/api/profile', requireAuth, (req, res) => {
res.json({
user: req.session.user,
scopes: req.session.tokenScope,
authenticated: true,
sessionId: req.sessionID,
});
});
// Logout via POST (evita logout CSRF com links ou imagens)
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Erro ao destruir sessão:', err);
return res.status(500).send('Erro ao terminar sessão.');
}
res.clearCookie('oauth_sid');
res.redirect('/');
});
});
// Tratamento global de erros (Express 5 propaga erros async automaticamente)
app.use((err, req, res, next) => {
console.error('Erro não tratado:', err.stack);
res.status(500).send('Erro interno do servidor.');
});
app.listen(PORT, () => {
console.log(`Servidor OAuth 2.0 PKCE a correr em http://localhost:${PORT}`);
console.log(`Callback configurado: ${process.env.REDIRECT_URI}`);
});
module.exports = app;
Passo 8: Testar o Fluxo OAuth Completo
Inicie o servidor e teste o fluxo OAuth completo:
# Iniciar o servidor em modo de desenvolvimento
npm run dev
# Saída esperada:
# Servidor OAuth 2.0 PKCE a correr em http://localhost:3000
# Callback configurado: http://localhost:3000/auth/callback
Abra o browser em http://localhost:3000 e clique em “Iniciar sessão com GitHub”. A sequência correta de eventos é:
- O browser acede a
/auth/login: o servidor gera code_verifier, code_challenge e state, guarda na sessão, e redireciona. - O browser chega a
https://github.com/login/oauth/authorize?client_id=...&state=abc123&code_challenge=xyz... - O utilizador clica “Authorize oauth2-pkce-tutorial” na página do GitHub.
- O GitHub redireciona para
http://localhost:3000/auth/callback?code=XXXX&state=abc123 - O servidor valida o state, troca o código por token, obtém perfil do GitHub.
- Redirect final para
http://localhost:3000/dashboardcom o utilizador autenticado.
Se tudo correr bem, o dashboard mostra o nome e username do GitHub. Para inspecionar os dados da sessão em JSON, aceda a http://localhost:3000/api/profile.
Passo 9: PKCE Completo com Fornecedores Compatíveis (Google, Keycloak)
Ao contrário do GitHub (que não implementa PKCE no fluxo web padrão), o Google Identity Platform, Microsoft Entra ID e Keycloak suportam PKCE completo. Para estes fornecedores, o code_verifier é enviado na troca do código, tornando o fluxo completamente resistente a interceção.
A alteração é mínima no ficheiro src/auth.js: descomente a linha code_verifier: codeVerifier no pedido de troca de token e configure o endpoint do fornecedor. Para o Google como exemplo:
// src/auth.js - versão com PKCE completo para Google OAuth 2.0
const GOOGLE_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
function startGoogleAuthFlow(req, res) {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
req.session.codeVerifier = codeVerifier;
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: process.env.REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
state: state,
code_challenge: codeChallenge, // PKCE: challenge vai no pedido de autorização
code_challenge_method: 'S256',
access_type: 'offline', // Receber refresh_token do Google
prompt: 'select_account',
});
res.redirect(`${GOOGLE_AUTHORIZE_URL}?${params.toString()}`);
}
async function handleGoogleCallback(req, res) {
const { code, state } = req.query;
if (!code || !state || state !== req.session.oauthState) {
return res.status(403).send('State inválido ou parâmetros em falta.');
}
const codeVerifier = req.session.codeVerifier;
const tokenResponse = await axios.post(GOOGLE_TOKEN_URL, {
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code: code,
redirect_uri: process.env.REDIRECT_URI,
grant_type: 'authorization_code',
code_verifier: codeVerifier, // PKCE: verifier vai na troca do código
});
const { access_token, refresh_token, expires_in } = tokenResponse.data;
req.session.accessToken = access_token;
req.session.refreshToken = refresh_token;
req.session.tokenExpiresAt = Date.now() + (expires_in * 1000);
delete req.session.codeVerifier;
delete req.session.oauthState;
req.session.save(() => res.redirect('/dashboard'));
}
Passo 10: Refresh Automático de Token
Os tokens de acesso do Google expiram ao fim de 3600 segundos (1 hora). Um middleware de refresh automático verifica a validade antes de cada chamada a APIs protegidas e renova o token de forma transparente:
// src/middleware.js - adicionar ao ficheiro existente
/**
* refreshTokenIfNeeded: renova o access_token se estiver dentro de 5 minutos da expiração.
* Apenas para fornecedores que emitem refresh_token (Google, Keycloak, etc.)
*/
async function refreshTokenIfNeeded(req, res, next) {
// Sem refresh_token disponível, continuar normalmente
if (!req.session.refreshToken) return next();
const expiresAt = req.session.tokenExpiresAt || 0;
const fiveMinutes = 5 * 60 * 1000;
const needsRefresh = Date.now() > (expiresAt - fiveMinutes);
if (!needsRefresh) return next();
try {
const axios = require('axios');
const response = await axios.post(process.env.TOKEN_ENDPOINT, {
grant_type: 'refresh_token',
refresh_token: req.session.refreshToken,
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
});
const { access_token, refresh_token, expires_in } = response.data;
req.session.accessToken = access_token;
// Alguns servidores rotacionam o refresh_token a cada uso
if (refresh_token) req.session.refreshToken = refresh_token;
req.session.tokenExpiresAt = Date.now() + (expires_in * 1000);
req.session.save(() => next());
} catch (error) {
console.error('Falha ao renovar token, forçando novo login:', error.message);
req.session.destroy(() => res.redirect('/auth/login'));
}
}
module.exports = { requireAuth, requireNoAuth, attachUser, refreshTokenIfNeeded };
Aplique o middleware nas rotas que chamam APIs externas:
// src/index.js - rota com refresh automático integrado
const { requireAuth, requireNoAuth, attachUser, refreshTokenIfNeeded } = require('./middleware');
app.get('/api/repos', requireAuth, refreshTokenIfNeeded, async (req, res) => {
try {
const response = await axios.get('https://api.github.com/user/repos?sort=updated&per_page=10', {
headers: { Authorization: `Bearer ${req.session.accessToken}` },
});
res.json(response.data.map(r => ({
name: r.full_name,
stars: r.stargazers_count,
language: r.language,
})));
} catch (error) {
if (error.response?.status === 401) {
// Token revogado pelo utilizador na conta GitHub
req.session.destroy(() => res.redirect('/auth/login'));
} else {
res.status(500).json({ error: 'Falha ao obter repositórios.' });
}
}
});
Passo 11: Segurança das Sessões em Produção
A sessão é o elo mais crítico desta implementação. Verifique cada configuração antes de lançar em produção:
| Configuração | Desenvolvimento | Produção | Risco se Incorreto |
|---|---|---|---|
cookie.httpOnly | true | true (obrigatório) | JavaScript rouba o cookie via XSS |
cookie.secure | false | true (HTTPS) | Cookie transmitido em texto claro por HTTP |
cookie.sameSite | ‘lax’ | ‘lax’ ou ‘strict’ | CSRF em pedidos cross-site |
secret | qualquer string | 64+ bytes aleatórios | Falsificação de sessão por força bruta |
saveUninitialized | false | false | Sessões vazias consomem memória desnecessariamente |
| Armazenamento | Em memória | Redis ou PostgreSQL | Sessões perdidas ao reiniciar o servidor |
Para produção com Redis, instale o adaptador e configure:
npm install connect-redis ioredis
// src/index.js - armazenamento Redis para produção
const { RedisStore } = require('connect-redis');
const Redis = require('ioredis');
const redisClient = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD,
tls: process.env.NODE_ENV === 'production' ? {} : undefined,
});
app.use(session({
store: new RedisStore({ client: redisClient, prefix: 'oauth:' }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400000 },
name: 'oauth_sid',
}));
Passo 12: Logout Seguro e Revogação de Token
Um logout seguro tem três componentes: destruição da sessão no servidor, limpeza do cookie no browser e, quando suportado, revogação explícita do token no Authorization Server. O GitHub suporta revogação via API com autenticação Basic do client_id e client_secret:
// src/index.js - logout completo com revogação de token
app.post('/auth/logout', async (req, res) => {
const accessToken = req.session.accessToken;
// 1. Revogar o token no GitHub (boa prática, não bloqueia o logout se falhar)
if (accessToken) {
try {
await axios.delete(
`https://api.github.com/applications/${process.env.GITHUB_CLIENT_ID}/token`,
{
auth: {
username: process.env.GITHUB_CLIENT_ID,
password: process.env.GITHUB_CLIENT_SECRET,
},
data: { access_token: accessToken },
headers: { Accept: 'application/vnd.github.v3+json' },
}
);
console.log('Token GitHub revogado com sucesso.');
} catch (err) {
console.warn('Aviso: falha ao revogar token:', err.message);
}
}
// 2. Destruir sessão no servidor (remove todos os dados da sessão)
req.session.destroy((err) => {
if (err) console.error('Erro ao destruir sessão:', err);
// 3. Instruir o browser a apagar o cookie
res.clearCookie('oauth_sid');
res.redirect('/');
});
});
O formulário HTML para o botão de logout usa POST, não GET. Um logout via GET é vulnerável a ataques de logout CSRF, onde uma imagem ou link malicioso numa página terceira pode desligar o utilizador silenciosamente:
<!-- Correto: logout via POST -->
<form action="/auth/logout" method="POST">
<button type="submit">Terminar Sessão</button>
</form>
<!-- ERRADO: logout via GET (vulnerável a logout CSRF) -->
<!-- <a href="/auth/logout">Sair</a> -->
Erros Comuns e Como Evitá-los
Estes são os 7 erros mais frequentes em implementações OAuth 2.0 com Node.js, documentados pelo OWASP OAuth 2.0 Cheat Sheet:
Erro 1: Não Validar o Parâmetro State
Omitir a validação do state no callback expõe a aplicação a ataques de login CSRF: um atacante força o utilizador a autenticar-se com a conta do atacante, permitindo acesso aos dados que o utilizador armazena na aplicação sem que este perceba. A solução é sempre verificar state === req.session.oauthState e rejeitar com HTTP 403 em caso de discrepância, como mostrado no Passo 5.
Erro 2: Guardar Tokens em localStorage
O localStorage é acessível a qualquer JavaScript na página, incluindo scripts injetados por XSS. Um único ataque XSS rouba todos os tokens. Os tokens devem existir apenas no servidor, dentro da sessão com cookie httpOnly. Este tutorial segue este padrão: o browser nunca vê o access_token.
Erro 3: Redirect URI com Validação Parcial
Aceitar redirect URIs com correspondência por prefixo (ex: qualquer URL que comece com https://app.exemplo.com) permite a um atacante usar https://app.exemplo.com.atacante.com para redirecionar o código de autorização para um domínio malicioso. Os Authorization Servers devem usar correspondência exata de URI. Ao registar a aplicação, use sempre o URI completo e exato.
Erro 4: Usar o Fluxo Implícito em Aplicações Novas
O fluxo implícito devolve o access_token diretamente no URL do redirect (#access_token=...). Este token fica no histórico do browser, em logs do servidor web e pode ser roubado por scripts na página. O OAuth 2.1 remove este fluxo por completo. Não existe nenhum caso de uso legítimo para fluxo implícito em aplicações novas.
Erro 5: Expor o Client Secret em Código Frontend
O client_secret é uma credencial do servidor. Incluí-lo em código JavaScript de frontend (React, Angular, Vue) ou em aplicações móveis expõe-no a qualquer pessoa que inspecione o bundle. Aplicações frontend puras são “public clients” e não devem ter client_secret: usam apenas PKCE. Para SPAs, use o padrão Backend-for-Frontend (BFF), onde o servidor Node.js gere os tokens e o frontend comunica apenas com o BFF.
Erro 6: Sessões Sem Prazo de Validade
Sessões que nunca expiram permitem que tokens roubados sejam usados indefinidamente. Defina sempre maxAge no cookie de sessão. Para operações sensíveis (alteração de password, pagamentos), re-autentique o utilizador mesmo com sessão ativa, em vez de confiar na sessão existente.
Erro 7: Não Limpar o PKCE Após o Callback
Manter o code_verifier e o oauthState na sessão após a troca de tokens bem-sucedida é um desperdício de memória e potencialmente um risco se a sessão for comprometida. O Passo 5 deste tutorial apaga estes valores imediatamente após o callback, com delete req.session.codeVerifier e delete req.session.oauthState.
Resolução de Problemas
Erro: “redirect_uri_mismatch”
Causa: O redirect_uri no pedido não coincide byte-a-byte com o registado no GitHub. Uma barra final, http vs https ou porta diferente causam este erro. Solução: Compare o valor de REDIRECT_URI no .env com o URL registado em GitHub > Settings > Developer settings > OAuth Apps.
Erro: “bad_verification_code”
Causa: O código de autorização foi usado mais do que uma vez (por exemplo, o utilizador refrescou a página do callback) ou já expirou. Os códigos do GitHub expiram ao fim de 10 minutos. Solução: Após o callback, redirecione imediatamente para outra página. Trate o erro devolvendo redirect para /auth/login.
Erro: State Inválido (HTTP 403)
Causa: A sessão expirou durante o fluxo OAuth, ou o utilizador abriu múltiplos tabs com fluxos paralelos (cada tab sobrescreve o state da sessão). Solução: Aumente o maxAge da sessão para pelo menos 15 minutos. Para múltiplos tabs, guarde um array de states válidos em vez de um único valor.
Erro: Token Devolvido como String URL-Encoded (GitHub)
Causa: O GitHub devolve o token em formato application/x-www-form-urlencoded por padrão. Sem o header Accept: application/json, o axios recebe access_token=gho_xxx&token_type=bearer como string. Solução: Sempre incluir headers: { Accept: 'application/json' } no pedido ao endpoint de token.
Erro: “Error: secret option required for sessions”
Causa: A variável SESSION_SECRET é undefined porque o require('dotenv').config() não foi chamado antes da configuração da sessão. Solução: Coloque require('dotenv').config() como primeira linha do src/index.js, antes de qualquer outro require.
Erro: Sessão Não Persistida Após Redirect
Causa: Alguns armazenamentos de sessão são assíncronos. O res.redirect() pode ser chamado antes de a sessão ser guardada. Solução: Usar req.session.save(callback) antes do redirect, como mostrado no Passo 5 deste tutorial.
Erro: Cookie Não Enviado em Produção com HTTPS
Causa: A aplicação está atrás de um proxy (Nginx, Cloudflare) e o Express não deteta HTTPS corretamente, por isso descarta o cookie secure: true. Solução: Adicione app.set('trust proxy', 1) antes da configuração de sessão para confiar no header X-Forwarded-Proto do proxy.
Erro: “Cannot GET /auth/callback” após Autorização
Causa: A rota /auth/callback não está definida ou o servidor não está a correr. Solução: Verifique que o servidor está ativo (npm run dev), que a rota existe no src/index.js, e que o REDIRECT_URI no .env aponta para localhost:3000 e não para outro porto.
Dicas Avançadas para Produção
Usar oauth4webapi para Implementações Multi-Fornecedor
Para aplicações de produção com vários fornecedores OAuth, o oauth4webapi é o cliente OpenID Certified recomendado para JavaScript e Node.js em 2026. Gera automaticamente PKCE, valida tokens JWT e suporta OpenID Connect. A biblioteca tem zero dependências externas e corre em Edge Runtime (Vercel, Cloudflare Workers), o que a torna adequada para arquiteturas serverless.
Implementar Deteção de Reutilização de Refresh Token
Com rotação de refresh tokens (suportada pelo Google e Keycloak), cada uso do refresh token invalida o anterior. Se o token antigo for apresentado novamente, indica que o original foi roubado. Implemente esta deteção: ao receber erro de refresh token inválido, destrua imediatamente TODAS as sessões do utilizador e notifique-o de potencial comprometimento de conta.
Adicionar Helmet.js para Headers de Segurança HTTP
XSS é a principal ameaça aos dados de sessão. Um Content Security Policy (CSP) restrito reduz drasticamente o risco. Adicione o pacote helmet ao servidor Express:
npm install helmet
// src/index.js - adicionar antes das rotas
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
connectSrc: ["'self'", 'api.github.com'],
imgSrc: ["'self'", 'avatars.githubusercontent.com', 'data:'],
},
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
Limitar Taxa de Pedidos nas Rotas OAuth
As rotas /auth/login e /auth/callback são alvos de ataques de força bruta e enumeração de códigos. Aplique rate limiting nestas rotas especificamente, com limites mais restritivos que no resto da aplicação (por exemplo, 10 pedidos por minuto por IP).
Usar Keycloak como Authorization Server Local
Para ambientes empresariais com SSO, o Keycloak oferece suporte completo a OAuth 2.0 com PKCE, OpenID Connect, refresh token rotation, e revogação de tokens. Em desenvolvimento, use Docker para executar uma instância local sem depender de serviços externos de terceiros:
docker run -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev
# Aceder à consola de administração:
# http://localhost:8080/admin
Comparação de Bibliotecas OAuth para Node.js em 2026
| Biblioteca | PKCE | OIDC | Zero Deps | Edge Runtime | Melhor Para |
|---|---|---|---|---|---|
| oauth4webapi | Sim | Sim (Certified) | Sim | Sim | Produção multi-fornecedor |
| openid-client | Sim | Sim (Certified) | Não | Parcial | Apps Node.js enterprise |
| simple-oauth2 5.1.0 | Manual | Não | Não | Não | OAuth 2.0 simples sem OIDC |
| passport-oauth2 1.8.0 | Manual | Não | Não | Não | Apps Passport.js existentes |
| Implementação nativa | Sim (manual) | Não | Sim | Sim | Aprendizagem e controlo total |
Cobertura Relacionada
Artigos Relacionados
- Autenticação de Dois Fatores em Node.js: 12 Passos (adicione TOTP como segundo fator após o login OAuth)
- mTLS em Node.js: TLS 1.3 em 12 Passos (use mTLS para Sender-Constrained tokens em APIs internas)
- Autenticação SSH com Chaves Ed25519: 12 Passos (criptografia de curva elíptica para autenticação segura)
- HTTPS no Nginx com Let’s Encrypt: 12 Passos (configure TLS no servidor antes de lançar OAuth em produção)
- Nmap: Auditar a Rede em 12 Passos (audite os endpoints OAuth expostos na infraestrutura)
Perguntas Frequentes
OAuth 2.0 e OpenID Connect são a mesma coisa?
Não. OAuth 2.0 é um protocolo de autorização: emite tokens de acesso para APIs protegidas. OpenID Connect (OIDC) é uma camada de identidade construída sobre OAuth 2.0: adiciona o id_token (um JWT com informação do utilizador) e o endpoint padronizado /userinfo. Para login de utilizadores (“quem é este utilizador?”), use OIDC. Para acesso delegado a APIs (“o utilizador autorizou esta ação?”), use OAuth 2.0 puro.
Preciso de PKCE se a minha aplicação tem um backend seguro com client_secret?
Sim. O RFC 9126 recomenda PKCE para todos os clientes OAuth, incluindo aplicações confidenciais com servidor. O risco de interceção de código é menor com um backend seguro, mas o PKCE adiciona defesa em profundidade sem custo computacional significativo. Com OAuth 2.1, o PKCE torna-se obrigatório para todos os clientes no fluxo de código de autorização.
Qual a diferença entre access_token e refresh_token?
O access_token é uma credencial de curta duração (1 hora no Google, indefinida no GitHub) usada para aceder a APIs protegidas. O refresh_token é uma credencial de longa duração usada exclusivamente para obter novos access_token sem intervenção do utilizador. O refresh_token exige maior proteção: o seu comprometimento permite acesso contínuo até revogação explícita. Guarde-o apenas no servidor, nunca no browser.
Como testar OAuth 2.0 localmente sem conta GitHub?
Use o Keycloak em Docker como Authorization Server local. Suporta PKCE completo, emite refresh tokens e permite simular múltiplos utilizadores e escopos sem depender de serviços externos. O comando de arranque está no Passo “Dicas Avançadas”. Aceda a http://localhost:8080/admin para criar realm, clientes e utilizadores de teste.
O que acontece se o utilizador revogar o acesso na conta GitHub?
O access_token torna-se inválido imediatamente. A próxima chamada à API do GitHub retorna 401 Unauthorized. A aplicação deve tratar este erro destruindo a sessão do utilizador e redirecionando para /auth/login. O Passo 10 deste tutorial mostra este tratamento dentro do bloco catch com verificação de error.response?.status === 401.
Qual a duração recomendada para access tokens e refresh tokens?
Para access tokens: 15 a 60 minutos para APIs de uso geral; 5 a 15 minutos para APIs de alto valor. Para refresh tokens: 7 dias para aplicações consumer com uso diário; 30 dias para enterprise com rotação ativa. Tokens de maior duração convencem os utilizadores, mas aumentam a janela de exploração após roubo. O modelo de ameaça da aplicação deve guiar esta decisão.
Posso usar OAuth 2.0 para autenticar utilizadores internos sem identidade federada?
Sim, com um Authorization Server próprio (Keycloak auto-hospedado ou @node-oauth/oauth2-server npm). Para utilizadores internos sem identidade federada, considere Passkeys (WebAuthn) como alternativa mais moderna: resistente a phishing, sem dependência de terceiros e com suporte nativo nos browsers atuais. OAuth faz mais sentido quando existe identidade federada, SSO empresarial ou acesso delegado a APIs de terceiros.




