Voltar para Artigos
Back-end★ Destaque16 min de leitura

Autenticação Híbrida: JWT Tradicional e OAuth2 com Google

Implementação completa de autenticação híbrida: modelagem flexível do banco, fluxo Authorization Code com PKCE, verificação de ID Token sem Passport, vinculação de contas e middleware JWT tipado.

12 de agosto de 2026

Construir autenticação que suporte múltiplos provedores parece complexo, mas o design certo torna isso elegante. O princípio é simples: independente de como o usuário autenticou (senha, Google, GitHub), a saída do processo é sempre a mesma — um par Access Token / Refresh Token interno. Seus middlewares de proteção de rota não sabem nem precisam saber qual provedor o usuário usou.

Neste artigo vamos construir do zero a autenticação híbrida: login com email/senha usando bcrypt + JWT, e OAuth2 com Google usando o fluxo Authorization Code com verificação de ID Token via google-auth-library — sem a complexidade do Passport.js quando você não precisa dela.

Modelagem do Banco: A Decisão Mais Importante

A chave para suportar múltiplos provedores sem duplicação é separar o conceito de conta de usuário do conceito de método de autenticação. A abordagem de campos opcionais (passwordHash, googleId na mesma tabela) funciona, mas adicionar um quarto provedor (GitHub, Apple ID) exigiria uma nova coluna — ALTER TABLE em produção no dia do lançamento do recurso. A tabela user_providers é mais código mas nunca precisa de alteração no schema quando novos provedores são adicionados.

  • Abordagem simples (campos opcionais) — uma tabela users com passwordHash e googleId opcionais. Funciona bem para até 2-3 provedores.
  • Abordagem escalável (tabela de providers) — tabela users + tabela user_providers com (provider, providerId, userId). Suporta n provedores sem mudar o schema.

Vamos usar a abordagem com tabela de providers, que é mais robusta:

UserModel.ts
import { Entity, PrimaryColumn, Column, OneToMany, CreateDateColumn } from 'typeorm';
import { UserProviderModel } from './UserProviderModel';

@Entity('users')
export class UserModel {
  @PrimaryColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column({ unique: true })
  email: string;

  // Hash da senha — null para usuários que só usam OAuth
  @Column({ name: 'password_hash', nullable: true, type: 'varchar' })
  passwordHash: string | null;

  @Column({ name: 'email_verified', default: false })
  emailVerified: boolean;

  // Usuários OAuth têm email verificado automaticamente pelo provedor
  @Column({ nullable: true, type: 'varchar' })
  avatar: string | null;

  @OneToMany(() => UserProviderModel, (provider) => provider.user, { cascade: true })
  providers: Array<UserProviderModel>;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}
UserProviderModel.ts
import { Entity, PrimaryColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm';
import { UserModel } from './UserModel';

@Entity('user_providers')
export class UserProviderModel {
  @PrimaryColumn('uuid')
  id: string;

  @Column({ name: 'user_id' })
  userId: string;

  @ManyToOne(() => UserModel, (user) => user.providers, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user: UserModel;

  // 'google', 'github', 'apple'
  @Column()
  provider: string;

  // ID do usuário no sistema do provedor
  @Column({ name: 'provider_id' })
  providerId: string;

  // Índice único: (provider, providerId) garante sem duplicação
  @Column({ name: 'provider_email', nullable: true })
  providerEmail: string | null;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

Autenticação com Email e Senha

AuthenticateWithPasswordUseCase.ts
import { compare } from 'bcrypt';
import { sign } from 'jsonwebtoken';
import { randomBytes } from 'crypto';
import { addDays } from 'date-fns';

interface AuthDTO { email: string; password: string; }

export class AuthenticateWithPasswordUseCase {
  constructor(
    private usersRepo: IUsersRepository,
    private refreshTokensRepo: IRefreshTokensRepository
  ) {}

  async execute({ email, password }: AuthDTO) {
    const user = await this.usersRepo.findByEmail(email);

    // Usuário não existe ou não tem senha (registrou via OAuth)
    // Sempre use a MESMA mensagem de erro para evitar enumeração de usuários
    if (!user || !user.passwordHash) {
      throw new AppError('Credenciais inválidas.', 401);
    }

    const isValidPassword = await compare(password, user.passwordHash);
    if (!isValidPassword) {
      throw new AppError('Credenciais inválidas.', 401);
    }

    return this.generateTokenPair(user.id, user.email, user.name);
  }

  // Método compartilhado entre todos os fluxos de autenticação
  async generateTokenPair(userId: string, email: string, name: string) {
    const accessToken = sign(
      { email, name },
      process.env.JWT_SECRET as string,
      { subject: userId, expiresIn: '15m' }
    );

    const refreshTokenValue = randomBytes(64).toString('hex');
    await this.refreshTokensRepo.create({
      id: crypto.randomUUID(),
      token: refreshTokenValue,
      userId,
      expiresAt: addDays(new Date(), 30),
      isRevoked: false,
    });

    return { accessToken, refreshToken: refreshTokenValue };
  }
}

Fluxo OAuth2 com Google (sem Passport.js)

O fluxo OAuth2 Authorization Code funciona assim:

  1. O frontend redireciona o usuário para a URL de autorização do Google com client_id, redirect_uri, scope e um state aleatório (proteção CSRF)
  2. O usuário aceita, o Google redireciona para seu backend com um code temporário
  3. Seu backend troca o code por um id_token chamando o endpoint do Google
  4. Você verifica o id_token localmente usando a chave pública do Google (ou via google-auth-library)
  5. Extrai os dados do usuário do ID Token e cria/vincula a conta
bash
npm install google-auth-library
AuthenticateWithGoogleUseCase.ts
import { OAuth2Client } from 'google-auth-library';

const googleClient = new OAuth2Client(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.GOOGLE_REDIRECT_URI
);

interface GoogleAuthDTO {
  code: string;
  codeVerifier?: string; // Para PKCE em apps mobile
}

export class AuthenticateWithGoogleUseCase {
  constructor(
    private usersRepo: IUsersRepository,
    private userProvidersRepo: IUserProvidersRepository,
    private authUseCase: AuthenticateWithPasswordUseCase
  ) {}

  async execute({ code }: GoogleAuthDTO) {
    // 1. Troca o code pelo conjunto de tokens do Google
    const { tokens } = await googleClient.getToken(code);

    if (!tokens.id_token) {
      throw new AppError('ID Token não retornado pelo Google.', 400);
    }

    // 2. Verifica e decodifica o ID Token
    // O google-auth-library valida a assinatura, audiência e expiração automaticamente
    const ticket = await googleClient.verifyIdToken({
      idToken: tokens.id_token,
      audience: process.env.GOOGLE_CLIENT_ID,
    });

    const payload = ticket.getPayload();
    if (!payload || !payload.email || !payload.email_verified) {
      throw new AppError('Token do Google inválido.', 401);
    }

    const { sub: googleId, email, name, picture } = payload;

    // 3. Verifica se já existe um provider vinculado
    const existingProvider = await this.userProvidersRepo.findByProvider('google', googleId);

    if (existingProvider) {
      // Usuário já conectou com Google antes — gera novos tokens
      const user = await this.usersRepo.findById(existingProvider.userId);
      return this.authUseCase.generateTokenPair(user!.id, user!.email, user!.name);
    }

    // 4. Verifica se existe conta com o mesmo email (vinculação de conta)
    const existingUser = await this.usersRepo.findByEmail(email);

    let userId: string;

    if (existingUser) {
      // Vincula o Google à conta existente
      userId = existingUser.id;
    } else {
      // Cria novo usuário (sem senha — somente OAuth)
      const newUser = await this.usersRepo.create({
        id: crypto.randomUUID(),
        name: name ?? 'Usuário Google',
        email,
        passwordHash: null,
        emailVerified: true, // Google já verificou
        avatar: picture ?? null,
      });
      userId = newUser.id;
    }

    // 5. Cria o registro do provider
    await this.userProvidersRepo.create({
      id: crypto.randomUUID(),
      userId,
      provider: 'google',
      providerId: googleId,
      providerEmail: email,
    });

    const user = await this.usersRepo.findById(userId);
    return this.authUseCase.generateTokenPair(user!.id, user!.email, user!.name);
  }
}

Gerando a URL de Autorização no Backend

GoogleAuthController.ts
import type { Request, Response } from 'express';
import { OAuth2Client } from 'google-auth-library';
import { randomBytes } from 'crypto';

const googleClient = new OAuth2Client(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.GOOGLE_REDIRECT_URI
);

export class GoogleAuthController {
  // GET /auth/google — redireciona para o Google
  async authorize(req: Request, res: Response): Promise<void> {
    // state: previne CSRF — salvo na sessão para validar no callback
    const state = randomBytes(16).toString('hex');
    req.session.oauthState = state;

    const url = googleClient.generateAuthUrl({
      access_type: 'offline',
      scope: ['openid', 'email', 'profile'],
      state,
      // prompt: 'consent' força o Google a mostrar a tela de consentimento
      // Necessário para receber refresh_token do Google no primeiro acesso
    });

    res.redirect(url);
  }

  // GET /auth/google/callback — recebe o code do Google
  async callback(req: Request, res: Response): Promise<void> {
    const { code, state, error } = req.query as Record<string, string>;

    if (error) {
      // Usuário cancelou o login no Google
      res.redirect(`${process.env.FRONTEND_URL}/login?error=oauth_cancelled`);
      return;
    }

    // Valida o state para prevenir CSRF
    if (state !== req.session.oauthState) {
      res.redirect(`${process.env.FRONTEND_URL}/login?error=invalid_state`);
      return;
    }

    const { accessToken, refreshToken } = await this.authenticateWithGoogle.execute({ code });

    // Refresh Token no Cookie, Access Token na URL (ou em outro Cookie)
    res.cookie('refresh_token', refreshToken, {
      httpOnly: true, secure: true, sameSite: 'strict',
      maxAge: 30 * 24 * 60 * 60 * 1000, path: '/auth/refresh',
    });

    // Redireciona para o frontend com o Access Token
    // (o frontend pega da URL e salva em memória)
    res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}`);
  }
}

Middleware de Autenticação

O middleware de autenticação é idêntico para ambos os fluxos — ele só valida o JWT e popula req.user:

ensureAuthenticated.ts
import type { Request, Response, NextFunction } from 'express';
import { verify, type JwtPayload } from 'jsonwebtoken';

interface TokenPayload extends JwtPayload {
  sub: string;   // userId
  email: string;
  name: string;
}

// Augmenta o tipo Request do Express
declare global {
  namespace Express {
    interface Request {
      user: { id: string; email: string; name: string };
    }
  }
}

export function ensureAuthenticated(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Token não fornecido.', code: 'token.missing' });
    return;
  }

  const token = authHeader.slice(7); // Remove 'Bearer '

  try {
    const payload = verify(token, process.env.JWT_SECRET as string) as TokenPayload;

    req.user = {
      id: payload.sub,
      email: payload.email,
      name: payload.name,
    };

    next();
  } catch (err) {
    // Diferencia token expirado de token inválido
    if (err instanceof Error && err.name === 'TokenExpiredError') {
      res.status(401).json({ error: 'Token expirado.', code: 'token.expired' });
      return;
    }
    res.status(401).json({ error: 'Token inválido.', code: 'token.invalid' });
  }
}

O código token.expired retornado no JSON é o que o Axios interceptor do frontend usa para distinguir entre expiração (→ tenta refresh) e token inválido (→ redireciona para login). Padronize sempre esse contrato entre backend e frontend.

Conclusão

O ponto central do design híbrido é que ambos os fluxos convergem para o mesmo output: um par de tokens internos (Access + Refresh). O middleware de proteção de rota não sabe nem precisa saber se o usuário autenticou via senha ou Google. Para adicionar GitHub amanhã, você cria um novo Use Case de autenticação e reutiliza generateTokenPair — sem mudar um byte do middleware ou das rotas protegidas.