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:

  1. O cliente gera um code_verifier: uma string aleatória de alta entropia (43 a 128 caracteres, base64url).
  2. O cliente calcula o code_challenge: BASE64URL(SHA256(code_verifier)).
  3. O cliente envia o code_challenge e code_challenge_method=S256 no pedido de autorização.
  4. O Authorization Server guarda o code_challenge associado ao código emitido.
  5. Na troca do código, o cliente envia o code_verifier original. O servidor verifica se SHA256(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

FluxoCaso de UsoPKCEEstado em OAuth 2.1
Authorization Code + PKCEAplicações web com servidor, SPAs, apps móveisObrigatórioPadrão recomendado
Client CredentialsMachine-to-machine sem utilizadorN/AMantido
Authorization Code (sem PKCE)Aplicações web legadasRecomendado pelo RFC 9126Descontinuado sem PKCE
Implicit FlowSPAs antigas com tokens no URLN/ARemovido
Resource Owner PasswordAplicações de confiança totalN/ARemovido
Device AuthorizationDispositivos sem browser (TV, CLI)N/AMantido

Pré-requisitos

Antes de começar, confirme que tem instalado e configurado:

  • Node.js 22 LTS ou superior (execute node --version para 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:

PacoteVersãoFunção
express5.2.1Framework web
express-session1.19.0Gestão de sessões no servidor
axios1.18.0Pedidos HTTP ao Authorization Server
dotenv17.4.2Variáveis de ambiente
crypto (built-in)Node.js 22Geraçã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:

  1. Aceda ao GitHub.com, clique na sua foto de perfil e selecione Settings.
  2. No menu lateral, clique em Developer settings (em baixo).
  3. Selecione OAuth Apps e clique em New OAuth App.
  4. 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}

Ver dados da sessão (JSON)

`); }); // 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 é:

  1. O browser acede a /auth/login: o servidor gera code_verifier, code_challenge e state, guarda na sessão, e redireciona.
  2. O browser chega a https://github.com/login/oauth/authorize?client_id=...&state=abc123&code_challenge=xyz...
  3. O utilizador clica “Authorize oauth2-pkce-tutorial” na página do GitHub.
  4. O GitHub redireciona para http://localhost:3000/auth/callback?code=XXXX&state=abc123
  5. O servidor valida o state, troca o código por token, obtém perfil do GitHub.
  6. Redirect final para http://localhost:3000/dashboard com 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çãoDesenvolvimentoProduçãoRisco se Incorreto
cookie.httpOnlytruetrue (obrigatório)JavaScript rouba o cookie via XSS
cookie.securefalsetrue (HTTPS)Cookie transmitido em texto claro por HTTP
cookie.sameSite‘lax’‘lax’ ou ‘strict’CSRF em pedidos cross-site
secretqualquer string64+ bytes aleatóriosFalsificação de sessão por força bruta
saveUninitializedfalsefalseSessões vazias consomem memória desnecessariamente
ArmazenamentoEm memóriaRedis ou PostgreSQLSessõ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.

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

BibliotecaPKCEOIDCZero DepsEdge RuntimeMelhor Para
oauth4webapiSimSim (Certified)SimSimProdução multi-fornecedor
openid-clientSimSim (Certified)NãoParcialApps Node.js enterprise
simple-oauth2 5.1.0ManualNãoNãoNãoOAuth 2.0 simples sem OIDC
passport-oauth2 1.8.0ManualNãoNãoNãoApps Passport.js existentes
Implementação nativaSim (manual)NãoSimSimAprendizagem e controlo total

Cobertura Relacionada

Artigos Relacionados

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.