OAuth2 com GitHub e Google em Node.js

Login social com Google e GitHub elimina o cadastro com senha para o usuário, e transfere a responsabilidade de autenticação segura para provedores com bilhões de usuários. Implementar OAuth2 do zero parece complexo, mas o fluxo Authorization Code com PKCE cabe em poucas funções.

Fluxo OAuth2: Authorization Code com PKCE

Entender o fluxo antes de implementar:

bash
# Fluxo OAuth2 Authorization Code com PKCE (Proof Key for Code Exchange):

# 1. Frontend gera code_verifier (aleatório) e code_challenge (SHA256 do verifier)
# 2. Frontend redireciona o usuário para o provedor OAuth com code_challenge
#    → https://github.com/login/oauth/authorize?
#        client_id=SEU_CLIENT_ID
#        &redirect_uri=https://api.seudominio.com.br/auth/github/callback
#        &scope=user:email
#        &state=CSRF_TOKEN_ALEATORIO
#        &code_challenge=BASE64URL(SHA256(code_verifier))
#        &code_challenge_method=S256

# 3. Usuário faz login no GitHub e autoriza
# 4. GitHub redireciona para redirect_uri com ?code=AUTHORIZATION_CODE&state=...
# 5. Backend verifica o state (anti-CSRF)
# 6. Backend troca code + code_verifier por access_token:
#    POST https://github.com/login/oauth/access_token
#    { client_id, client_secret, code, code_verifier, redirect_uri }
# 7. Backend usa access_token para buscar perfil do usuário
# 8. Backend cria/atualiza usuário no banco e gera JWT próprio para a sessão

# Por que PKCE?
# Protege contra roubo do authorization code em trânsito
# Obrigatório para SPAs (sem client_secret seguro no browser)

OAuth2 com GitHub: implementação manual

Implementar o fluxo OAuth2 sem bibliotecas externas:

typescript
// routes/auth.ts — OAuth2 com GitHub

// Passo 1: Iniciar o fluxo (frontend chama esta rota):
app.get('/api/auth/github', (req, res) => {
  const state = crypto.randomUUID()
  // Armazenar state na sessão ou cookie para verificação no callback:
  res.cookie('oauth_state', state, { httpOnly: true, maxAge: 600_000 })

  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID!,
    redirect_uri: process.env.GITHUB_REDIRECT_URI!,
    scope: 'user:email',
    state,
  })

  res.redirect(`https://github.com/login/oauth/authorize?${params}`)
})

// Passo 2: Callback do GitHub:
app.get('/api/auth/github/callback', async (req, res) => {
  const { code, state } = req.query as { code: string; state: string }
  const storedState = req.cookies.oauth_state

  // Verificar CSRF (state deve bater):
  if (!state || state !== storedState) {
    return res.status(400).json({ error: 'State inválido (possível CSRF)' })
  }
  res.clearCookie('oauth_state')

  // Trocar code por access_token do GitHub:
  const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify({
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_CLIENT_SECRET,
      code,
      redirect_uri: process.env.GITHUB_REDIRECT_URI,
    }),
  })
  const { access_token } = await tokenRes.json() as { access_token: string }

  // Buscar perfil do usuário:
  const profileRes = await fetch('https://api.github.com/user', {
    headers: { Authorization: `Bearer ${access_token}`, 'User-Agent': 'minha-api' },
  })
  const profile = await profileRes.json() as { id: number; login: string; email: string; name: string }

  // Buscar email (pode ser privado):
  const emailsRes = await fetch('https://api.github.com/user/emails', {
    headers: { Authorization: `Bearer ${access_token}`, 'User-Agent': 'minha-api' },
  })
  const emails = await emailsRes.json() as Array<{ email: string; primary: boolean; verified: boolean }>
  const primaryEmail = emails.find(e => e.primary && e.verified)?.email ?? profile.email

  // Upsert do usuário no banco:
  const usuario = await db.query(
    `INSERT INTO usuarios (github_id, email, nome, avatar_url)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (github_id) DO UPDATE SET email = EXCLUDED.email, nome = EXCLUDED.nome
     RETURNING *`,
    [profile.id, primaryEmail, profile.name, `https://github.com/${profile.login}.png`]
  ).then(r => r.rows[0])

  // Gerar JWT próprio e redirecionar para o frontend:
  const accessToken = await gerarAccessToken({ userId: usuario.id, email: usuario.email, role: usuario.role })
  res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}`)
})

OAuth2 com Google

Login com Google usando a API de identidade do Google:

typescript
// Google usa OpenID Connect (extensão do OAuth2) — expõe um id_token JWT

app.get('/api/auth/google', (req, res) => {
  const state = crypto.randomUUID()
  res.cookie('oauth_state', state, { httpOnly: true, maxAge: 600_000 })

  const params = new URLSearchParams({
    client_id: process.env.GOOGLE_CLIENT_ID!,
    redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
    response_type: 'code',
    scope: 'openid email profile',
    state,
    access_type: 'offline',   // para receber refresh_token do Google
    prompt: 'consent',
  })

  res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`)
})

app.get('/api/auth/google/callback', async (req, res) => {
  const { code, state } = req.query as { code: string; state: string }
  if (state !== req.cookies.oauth_state) return res.status(400).send('State inválido')
  res.clearCookie('oauth_state')

  // Trocar code por tokens:
  const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
      grant_type: 'authorization_code',
    }),
  })
  const { access_token, id_token } = await tokenRes.json()

  // Verificar o id_token JWT do Google (contém o perfil):
  // Opção 1: usar google-auth-library
  // Opção 2: buscar perfil via API:
  const profileRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
    headers: { Authorization: `Bearer ${access_token}` },
  })
  const profile = await profileRes.json() as {
    sub: string; email: string; name: string; picture: string; email_verified: boolean
  }

  if (!profile.email_verified) return res.status(400).json({ error: 'Email não verificado' })

  const usuario = await upsertUsuario({
    googleId: profile.sub,
    email: profile.email,
    nome: profile.name,
    avatarUrl: profile.picture,
  })

  const accessToken = await gerarAccessToken({ userId: usuario.id, email: usuario.email, role: usuario.role })
  res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}`)
})

Configurar as credenciais OAuth2

Criar apps no GitHub e Google para obter client_id e client_secret:

bash
# GitHub — criar OAuth App:
# 1. github.com/settings/developers → OAuth Apps → New OAuth App
# 2. Application name: Minha API
# 3. Homepage URL: https://seudominio.com.br
# 4. Authorization callback URL: https://api.seudominio.com.br/api/auth/github/callback
# 5. Copiar: Client ID e Client Secret (gerar com "Generate a new client secret")

# Google — criar credenciais OAuth2:
# 1. console.cloud.google.com → Criar projeto
# 2. APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client IDs
# 3. Application type: Web application
# 4. Authorized redirect URIs: https://api.seudominio.com.br/api/auth/google/callback
# 5. Copiar: Client ID e Client Secret
# 6. APIs & Services → OAuth consent screen → configurar domínio e email de suporte

# .env de produção:
GITHUB_CLIENT_ID=Ov23liXXXXXXXXXXXXXX
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_REDIRECT_URI=https://api.seudominio.com.br/api/auth/github/callback

GOOGLE_CLIENT_ID=123456789-xxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://api.seudominio.com.br/api/auth/google/callback

FRONTEND_URL=https://seudominio.com.br

Passport.js: simplificar múltiplos providers

Usar Passport.js para gerenciar vários provedores OAuth2:

typescript
// npm install passport passport-github2 passport-google-oauth20

import passport from 'passport'
import { Strategy as GithubStrategy } from 'passport-github2'
import { Strategy as GoogleStrategy } from 'passport-google-oauth20'

passport.use(new GithubStrategy({
  clientID: process.env.GITHUB_CLIENT_ID!,
  clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  callbackURL: process.env.GITHUB_REDIRECT_URI!,
  scope: ['user:email'],
}, async (accessToken, refreshToken, profile, done) => {
  const email = profile.emails?.[0]?.value
  const usuario = await upsertUsuario({
    githubId: profile.id,
    email: email ?? '',
    nome: profile.displayName,
  })
  done(null, usuario)
}))

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  callbackURL: process.env.GOOGLE_REDIRECT_URI!,
}, async (accessToken, refreshToken, profile, done) => {
  const email = profile.emails?.[0]?.value
  const usuario = await upsertUsuario({
    googleId: profile.id,
    email: email ?? '',
    nome: profile.displayName,
  })
  done(null, usuario)
}))

app.use(passport.initialize())

// Rotas:
app.get('/api/auth/github', passport.authenticate('github'))
app.get('/api/auth/github/callback',
  passport.authenticate('github', { session: false }),
  async (req, res) => {
    const usuario = req.user as Usuario
    const token = await gerarAccessToken({ userId: usuario.id, email: usuario.email, role: usuario.role })
    res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${token}`)
  }
)

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

Para começar: Passport.js com as strategies prontas (passport-github2, passport-google-oauth20) é mais rápido e menos propenso a erros de implementação do fluxo. Para controle total e menos dependências: a implementação manual com fetch nativo não é complexa e o código fica transparente. Para Next.js: Auth.js (next-auth) resolve tudo em poucas linhas com dezenas de providers built-in.

Estratégia de merge: ao receber o perfil OAuth, buscar usuário pelo email. Se existe: vincular o provider ao usuário existente (adicionar coluna github_id ou oauth_providers JSONB). Se não existe: criar novo usuário. Cuidado: nunca confie automaticamente no email do OAuth para merge sem verificação adicional, alguns provedores permitem emails não verificados.

PKCE é obrigatório para SPAs e apps mobile (sem server-side seguro para guardar o client_secret). Para aplicações server-side (Node.js backend): o client_secret é guardado seguramente no servidor, então PKCE é opcional mas recomendado por adicionar uma camada extra de segurança. Para fluxo Authorization Code tradicional com client_secret: ainda seguro sem PKCE no backend.

Quando o access_token do provedor expira ou é revogado, as chamadas à API do provedor falham. Para sua aplicação: isso não afeta o JWT próprio que você emitiu, ele continua válido até expirar. Para forçar reautenticação: monitore erros 401 nas chamadas ao provedor e revogue o refresh token do usuário, forçando novo login. Para dados críticos do perfil: implemente webhook de revogação se o provedor oferecer.

GitHub e Google exigem redirect_uri registrado. Para local: registre http://localhost:3000/api/auth/github/callback separadamente nas configurações do app OAuth. Ou use ngrok/localtunnel para expor o localhost com HTTPS (os provedores exigem HTTPS em produção). Auth.js tem um provedor "Credentials" para simular login sem OAuth em desenvolvimento.