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.
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
userscompasswordHashegoogleIdopcionais. Funciona bem para até 2-3 provedores. - Abordagem escalável (tabela de providers) — tabela
users+ tabelauser_providerscom(provider, providerId, userId). Suporta n provedores sem mudar o schema.
Vamos usar a abordagem com tabela de providers, que é mais robusta:
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;
}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
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:
- O frontend redireciona o usuário para a URL de autorização do Google com
client_id,redirect_uri,scopee umstatealeatório (proteção CSRF) - O usuário aceita, o Google redireciona para seu backend com um
codetemporário - Seu backend troca o
codepor umid_tokenchamando o endpoint do Google - Você verifica o
id_tokenlocalmente usando a chave pública do Google (ou viagoogle-auth-library) - Extrai os dados do usuário do ID Token e cria/vincula a conta
npm install google-auth-libraryimport { 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
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:
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.