O que é OAuth 2.0?
OAuth 2.0 (RFC 6749) é um framework de autorização que permite que uma aplicação acesse recursos em nome de um usuário sem precisar das suas credenciais. Ele delega a decisão de acesso ao dono do recurso.
Importante: OAuth 2.0 é sobre autorização (o que você pode fazer), não autenticação (quem você é). Para autenticação, use OpenID Connect (OIDC) em cima do OAuth 2.0.
Papéis (Roles)
| Papel | Descrição | Exemplo |
|---|---|---|
| Resource Owner | Usuário dono dos dados | Você, o usuário final |
| Client | Aplicação que quer acessar os dados | Seu app React/mobile |
| Authorization Server | Emite tokens após autenticação | Keycloak, Auth0, Google |
| Resource Server | API que protege os recursos | Sua API Spring Boot |
Tokens
Access Token
- Credencial de curta duração para acessar recursos protegidos
- Enviado no header
Authorization: Bearer <token> - Pode ser opaco (random string) ou estruturado (JWT)
- Vida típica: 5-15 minutos
Refresh Token
- Credencial de longa duração para obter novos access tokens sem novo login
- Nunca enviado para a Resource Server
- Fica no Authorization Server
- Vida típica: dias a meses
ID Token (OIDC)
- JWT que contém informações de identidade do usuário
- Retornado junto com o access token no fluxo Authorization Code com
scope: openid - Destinado ao client, não à API
- Contém:
sub,name,email,picture,email_verified, etc.
Token Response Típico
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "8xLOxBtZp8",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"scope": "openid profile email"
}Escopos (Scopes)
Escopos definem o nível de acesso solicitado. O usuário aprova os escopos na tela de consentimento.
Escopos OIDC padrão:
| Scope | Claims retornadas |
|---|---|
openid | sub (obrigatório para OIDC) |
profile | name, family_name, given_name, picture, locale |
email | email, email_verified |
address | address |
phone | phone_number, phone_number_verified |
offline_access | Habilita refresh token |
Escopos customizados (sua API):
read:users # leitura de usuários
write:users # criação/atualização de usuários
admin # acesso administrativo
orders:read # leitura de pedidosGrant Types
Quando usar qual?
| Cenário | Grant Type |
|---|---|
| App web/mobile com usuário logando | Authorization Code + PKCE |
| Comunicação máquina-a-máquina (M2M) | Client Credentials |
| TV, CLI, dispositivo sem browser | Device Authorization |
Authorization Code + PKCE (Fluxo Completo)
Este é o fluxo principal para qualquer aplicação que envolva usuário. PKCE (Proof Key for Code Exchange) protege contra interceptação do código.
Passo a Passo
1. GERAR CODE VERIFIER E CHALLENGE (client)
code_verifier = base64url(random(32 bytes))
code_challenge = base64url(SHA-256(code_verifier))
2. REDIRECIONAR PARA O AUTHORIZATION SERVER
GET https://auth.server.com/authorize?
response_type=code
&client_id=meu-app
&redirect_uri=https://app.com/callback
&scope=openid profile email
&state=RANDOM_STATE_VALUE
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
3. USUÁRIO AUTENTICA NO AUTHORIZATION SERVER
(login, MFA, consentimento)
4. AUTHORIZATION SERVER REDIRECIONA DE VOLTA
GET https://app.com/callback?
code=AUTH_CODE_123
&state=RANDOM_STATE_VALUE
5. TROCAR CÓDIGO POR TOKENS (server-side)
POST https://auth.server.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE_123
&redirect_uri=https://app.com/callback
&client_id=meu-app
&client_secret=SECRET (apenas para confidential clients)
&code_verifier=CODE_VERIFIER_ORIGINAL
6. RECEBER TOKENS
{
"access_token": "...",
"refresh_token": "...",
"id_token": "...",
"expires_in": 900
}Implementação em JavaScript (SPA)
import crypto from 'crypto'; // Node.js — no browser use window.crypto
// Passo 1: Gerar PKCE
async function generatePKCE() {
// Code verifier: string aleatória de 43-128 chars
const verifier = base64UrlEncode(crypto.randomBytes(32));
// Code challenge: SHA-256 do verifier, encodado em base64url
const challenge = base64UrlEncode(
crypto.createHash('sha256').update(verifier).digest()
);
return { verifier, challenge };
}
function base64UrlEncode(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Passo 2: Iniciar fluxo de login
async function login() {
const { verifier, challenge } = await generatePKCE();
const state = base64UrlEncode(crypto.randomBytes(16));
// Salvar para usar no callback
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'meu-spa',
redirect_uri: 'https://app.com/callback',
scope: 'openid profile email offline_access',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `https://auth.server.com/authorize?${params}`;
}
// Passo 4-6: Processar callback
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
if (error) {
throw new Error(`OAuth erro: ${error} - ${params.get('error_description')}`);
}
// Validar state para prevenir CSRF
const savedState = sessionStorage.getItem('oauth_state');
if (state !== savedState) {
throw new Error('State inválido — possível ataque CSRF');
}
const verifier = sessionStorage.getItem('pkce_verifier');
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
// Trocar código por tokens (via backend para não expor client_secret)
const response = await fetch('/api/auth/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: verifier }),
});
const tokens = await response.json();
// Armazenar access_token em memória, refresh_token em httpOnly cookie
return tokens;
}Servidor Node.js — Troca do código
import express from 'express';
import axios from 'axios';
const router = express.Router();
router.post('/api/auth/callback', async (req, res) => {
const { code, code_verifier } = req.body;
try {
// Troca código por tokens no authorization server
const tokenResponse = await axios.post(
'https://auth.server.com/token',
new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.com/callback',
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!, // apenas confidential clients
code_verifier,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const { access_token, refresh_token, id_token, expires_in } = tokenResponse.data;
// Refresh token em httpOnly cookie
res.cookie('refresh_token', refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dias
path: '/api/auth',
});
// Retorna access token e id token para o cliente
res.json({ access_token, id_token, expires_in });
} catch (err) {
res.status(400).json({ error: 'Falha ao trocar código por tokens' });
}
});Client Credentials (Machine-to-Machine)
Para comunicação entre serviços sem usuário envolvido.
Serviço A (Client) Authorization Server
| |
|-- POST /token ------------------>|
| grant_type=client_credentials |
| client_id=servico-a |
| client_secret=SECRET |
| scope=read:orders |
| |-- valida credenciais
|<-- access_token -----------------|
| |
Serviço A Serviço B (Resource Server)
| |
|-- GET /orders ------------------->|
| Authorization: Bearer <token> |
| |-- valida token (introspect ou JWT)
|<-- 200 OK + dados ---------------|Implementação em Java (WebClient)
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.web.reactive.function.client.WebClient;
@Service
public class OrderServiceClient {
private final WebClient webClient;
private final OAuth2AuthorizedClientManager authorizedClientManager;
// Spring Security gerencia automaticamente o token e o refresh
public List<Order> getOrders() {
OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest
.withClientRegistrationId("servico-b") // definido em application.yaml
.principal("servico-a")
.build();
var authorizedClient = authorizedClientManager.authorize(request);
String token = authorizedClient.getAccessToken().getTokenValue();
return webClient.get()
.uri("https://servico-b/orders")
.headers(h -> h.setBearerAuth(token))
.retrieve()
.bodyToFlux(Order.class)
.collectList()
.block();
}
}# application.yaml — configuração do client credentials
spring:
security:
oauth2:
client:
registration:
servico-b:
provider: keycloak
client-id: servico-a
client-secret: ${OAUTH_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: read:orders
provider:
keycloak:
token-uri: https://keycloak.example.com/realms/meu-realm/protocol/openid-connect/tokenImplementação em Python (httpx)
import httpx
from datetime import datetime, timedelta
import threading
class OAuth2ClientCredentials:
"""Gerencia tokens para client credentials flow com cache automático."""
def __init__(self, token_url: str, client_id: str, client_secret: str, scopes: list[str]):
self.token_url = token_url
self.client_id = client_id
self.client_secret = client_secret
self.scopes = scopes
self._token: str | None = None
self._expires_at: datetime | None = None
self._lock = threading.Lock()
def get_token(self) -> str:
with self._lock:
# Renova 60 segundos antes de expirar
if self._token and self._expires_at and datetime.now() < self._expires_at - timedelta(seconds=60):
return self._token
self._refresh_token()
return self._token # type: ignore
def _refresh_token(self):
response = httpx.post(
self.token_url,
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": " ".join(self.scopes),
},
)
response.raise_for_status()
data = response.json()
self._token = data["access_token"]
self._expires_at = datetime.now() + timedelta(seconds=data["expires_in"])
# Uso
client = OAuth2ClientCredentials(
token_url="https://auth.server.com/token",
client_id="meu-servico",
client_secret="SECRET",
scopes=["read:orders", "write:invoices"],
)
with httpx.Client() as http:
response = http.get(
"https://api.exemplo.com/orders",
headers={"Authorization": f"Bearer {client.get_token()}"},
)Device Authorization Grant (Device Flow)
Para dispositivos sem browser ou com entrada limitada (TV, CLI, IoT).
1. Dispositivo solicita device code
POST /device/authorize
client_id=meu-tv-app
scope=openid profile
Resposta:
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.server.com/device",
"expires_in": 1800,
"interval": 5
}
2. Dispositivo mostra para o usuário:
"Acesse https://auth.server.com/device e insira o código WDJB-MJHT"
3. Dispositivo faz polling enquanto espera
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code
device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS
client_id=meu-tv-app
→ Resposta durante espera: {"error": "authorization_pending"}
→ Após aprovação: { access_token, refresh_token, ... }import httpx, time
def device_flow_login(token_url: str, client_id: str, scope: str = "openid profile"):
# Passo 1: obter device code
resp = httpx.post(
token_url.replace("/token", "/device/authorize"),
data={"client_id": client_id, "scope": scope},
)
resp.raise_for_status()
device = resp.json()
print(f"\nAcesse: {device['verification_uri']}")
print(f"Código: {device['user_code']}\n")
# Passo 3: polling
interval = device.get("interval", 5)
while True:
time.sleep(interval)
token_resp = httpx.post(
token_url,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device["device_code"],
"client_id": client_id,
},
)
data = token_resp.json()
if "access_token" in data:
return data
error = data.get("error")
if error == "authorization_pending":
continue
elif error == "slow_down":
interval += 5 # aumenta o intervalo
elif error == "expired_token":
raise TimeoutError("Código expirou. Tente novamente.")
elif error == "access_denied":
raise PermissionError("Acesso negado pelo usuário.")
else:
raise RuntimeError(f"Erro inesperado: {data}")OpenID Connect (OIDC)
OIDC é uma camada de identidade em cima do OAuth 2.0. Adiciona:
- ID Token (JWT com dados do usuário)
- UserInfo Endpoint (mais dados do usuário via access token)
- Discovery Document (
.well-known/openid-configuration)
ID Token — Estrutura
{
"iss": "https://auth.server.com/realms/meu-realm",
"sub": "user-uuid-123",
"aud": "meu-client-id",
"exp": 1700003600,
"iat": 1700000000,
"auth_time": 1699999900,
"nonce": "n-0S6_WzA2Mj",
"acr": "1",
"at_hash": "MTIzNDU2Nzg5MA",
"name": "Rafael Marques",
"given_name": "Rafael",
"family_name": "Marques",
"email": "rafael@example.com",
"email_verified": true,
"locale": "pt-BR",
"picture": "https://example.com/avatar.jpg"
}O ID Token é para o client identificar quem o usuário é. Não deve ser enviado para a API como access token.
UserInfo Endpoint
# Obter informações adicionais do usuário
GET https://auth.server.com/userinfo
Authorization: Bearer <access_token>
# Resposta
{
"sub": "user-uuid-123",
"name": "Rafael Marques",
"email": "rafael@example.com",
"email_verified": true,
"updated_at": 1699999900
}Discovery Document
# Autodiscovery — encontrar todos os endpoints automaticamente
GET https://auth.server.com/realms/meu-realm/.well-known/openid-configuration
# Resposta inclui:
{
"issuer": "https://auth.server.com/realms/meu-realm",
"authorization_endpoint": "https://auth.server.com/..../authorize",
"token_endpoint": "https://auth.server.com/..../token",
"userinfo_endpoint": "https://auth.server.com/..../userinfo",
"jwks_uri": "https://auth.server.com/..../certs",
"end_session_endpoint": "https://auth.server.com/..../logout",
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"id_token_signing_alg_values_supported": ["RS256", "ES256"]
}Token Introspection e Validação
Para tokens opacos (não-JWT), o Resource Server precisa perguntar ao Authorization Server se o token é válido.
Token Introspection (RFC 7662)
POST https://auth.server.com/introspect
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded
token=ACCESS_TOKEN_AQUI
# Resposta
{
"active": true,
"sub": "user-uuid-123",
"username": "rafael",
"email": "rafael@example.com",
"scope": "read:orders write:orders",
"client_id": "meu-app",
"exp": 1700003600,
"iat": 1700000000
}
# Ou se inválido/expirado
{ "active": false }Validação Local de JWT (JWKS)
Para tokens JWT, o Resource Server pode validar localmente usando as chaves públicas:
// Spring Security — configuração do Resource Server
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("admin")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwkSetUri("https://auth.server.com/realms/meu-realm/protocol/openid-connect/certs")
)
);
return http.build();
}
// Customizar extração de roles do JWT
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("realm_access.roles");
converter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
}Keycloak — Configuração Completa
Estrutura do Keycloak
Keycloak Instance
└── Realm (tenant isolado)
├── Clients (suas aplicações)
│ ├── meu-spa (public, Authorization Code + PKCE)
│ ├── meu-backend-api (bearer-only ou confidential)
│ └── servico-worker (confidential, client_credentials)
├── Users
├── Groups
├── Roles
│ ├── Realm Roles (ex: admin, user, moderador)
│ └── Client Roles (ex: meu-backend:read, meu-backend:write)
└── Identity Providers (Google, GitHub, LDAP, SAML)Criando Realm via Admin API
# Autenticar como admin
curl -X POST https://keycloak.example.com/realms/master/protocol/openid-connect/token \
-d "client_id=admin-cli&username=admin&password=admin&grant_type=password" \
| jq .access_token
# Criar realm
curl -X POST https://keycloak.example.com/admin/realms \
-H "Authorization: Bearer ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"realm": "meu-produto",
"enabled": true,
"displayName": "Meu Produto",
"accessTokenLifespan": 900,
"refreshTokenMaxReuse": 0,
"revokeRefreshToken": true
}'Configuração de Client para SPA
{
"clientId": "meu-spa",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"rootUrl": "https://app.exemplo.com",
"redirectUris": [
"https://app.exemplo.com/callback",
"http://localhost:5173/callback"
],
"webOrigins": [
"https://app.exemplo.com",
"http://localhost:5173"
],
"attributes": {
"pkce.code.challenge.method": "S256"
}
}Configuração de Client para Backend API (Resource Server)
{
"clientId": "meu-backend-api",
"enabled": true,
"publicClient": false,
"bearerOnly": true,
"standardFlowEnabled": false
}Configuração de Client para M2M (Client Credentials)
{
"clientId": "servico-worker",
"enabled": true,
"publicClient": false,
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false
}Mapper de Roles no Token
Por padrão o Keycloak inclui roles assim no token:
{
"realm_access": {
"roles": ["admin", "default-roles-meu-produto"]
},
"resource_access": {
"meu-backend-api": {
"roles": ["read", "write"]
}
}
}Para incluir roles como lista flat (mais simples):
// Adicionar Protocol Mapper ao client:
{
"name": "roles-flat",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"claim.name": "roles",
"jsonType.label": "String",
"multivalued": "true",
"access.token.claim": "true"
}
}Spring Security + OAuth2
Resource Server Completo
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // habilita @PreAuthorize
public class SecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").hasAuthority("SCOPE_read:products")
.requestMatchers(HttpMethod.POST, "/api/products/**").hasAuthority("SCOPE_write:products")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withIssuerLocation(issuerUri)
.build();
// Validações adicionais
OAuth2TokenValidator<Jwt> defaultValidator = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD,
aud -> aud != null && aud.contains("meu-backend-api")
);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
defaultValidator, audienceValidator
));
return decoder;
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter rolesConverter = new JwtGrantedAuthoritiesConverter();
// Extrai roles do campo customizado
rolesConverter.setAuthoritiesClaimName("roles");
rolesConverter.setAuthorityPrefix("ROLE_");
JwtGrantedAuthoritiesConverter scopesConverter = new JwtGrantedAuthoritiesConverter();
// scopesConverter usa "scope" por padrão com prefixo "SCOPE_"
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(
jwt -> {
var roles = rolesConverter.convert(jwt);
var scopes = scopesConverter.convert(jwt);
var all = new java.util.ArrayList<>(roles);
all.addAll(scopes);
return all;
}
);
return converter;
}
}Usando @PreAuthorize
@RestController
@RequestMapping("/api/users")
public class UserController {
// Requer ROLE_admin
@GetMapping
@PreAuthorize("hasRole('admin')")
public List<UserDTO> listUsers() { ... }
// Requer scope read:users
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('SCOPE_read:users')")
public UserDTO getUser(@PathVariable String id) { ... }
// Combinação: admin OU dono do recurso
@GetMapping("/{id}/profile")
@PreAuthorize("hasRole('admin') or #id == authentication.name")
public ProfileDTO getProfile(@PathVariable String id) { ... }
// Acesso ao JWT completo
@GetMapping("/me")
public UserDTO getMe(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
String email = jwt.getClaimAsString("email");
List<String> roles = jwt.getClaimAsStringList("roles");
return userService.findById(userId);
}
}application.yaml para Resource Server
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/meu-realm
# jwk-set-uri: alternativa se issuer-uri não funcionar
# jwk-set-uri: https://keycloak.example.com/realms/meu-realm/protocol/openid-connect/certsImplementação Node.js
express-openid-connect (Okta/Auth0 SDK — simples)
npm install express-openid-connect expressconst { auth, requiresAuth } = require('express-openid-connect');
const express = require('express');
const app = express();
app.use(auth({
authRequired: false, // permite rotas públicas
auth0Logout: true,
baseURL: 'http://localhost:3000',
clientID: process.env.CLIENT_ID,
issuerBaseURL: process.env.ISSUER_BASE_URL, // ex: https://auth.server.com
secret: process.env.SESSION_SECRET,
clientSecret: process.env.CLIENT_SECRET,
authorizationParams: {
scope: 'openid profile email offline_access',
audience: 'https://api.exemplo.com',
},
}));
// Rota pública
app.get('/', (req, res) => {
res.send(req.oidc.isAuthenticated() ? 'Logado!' : 'Deslogado');
});
// Rota protegida
app.get('/profile', requiresAuth(), (req, res) => {
res.json(req.oidc.user);
});passport-oauth2 (mais controle)
npm install passport passport-oauth2 express-sessionimport passport from 'passport';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import session from 'express-session';
import express from 'express';
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
}));
app.use(passport.initialize());
app.use(passport.session());
passport.use(new OAuth2Strategy(
{
authorizationURL: 'https://auth.server.com/authorize',
tokenURL: 'https://auth.server.com/token',
clientID: process.env.CLIENT_ID!,
clientSecret: process.env.CLIENT_SECRET!,
callbackURL: 'http://localhost:3000/auth/callback',
scope: ['openid', 'profile', 'email'],
pkce: true, // ativa PKCE automaticamente
state: true, // ativa state anti-CSRF
},
(accessToken, refreshToken, profile, done) => {
// Aqui você busca/cria o usuário no banco
return done(null, { accessToken, refreshToken, profile });
}
));
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user: any, done) => done(null, user));
app.get('/auth/login', passport.authenticate('oauth2'));
app.get('/auth/callback',
passport.authenticate('oauth2', { failureRedirect: '/login' }),
(req, res) => res.redirect('/')
);
app.get('/protected',
(req, res, next) => req.isAuthenticated() ? next() : res.redirect('/auth/login'),
(req, res) => res.json(req.user)
);Validação de JWT Bearer Token (Middleware Node.js)
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.server.com/realms/meu-realm/protocol/openid-connect/certs',
cache: true,
cacheMaxAge: 600000, // 10 min
});
function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
client.getSigningKey(header.kid, (err, key) => {
const signingKey = key?.getPublicKey();
callback(err, signingKey);
});
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token ausente' });
}
const token = auth.slice(7);
jwt.verify(
token,
getKey,
{
algorithms: ['RS256'],
issuer: 'https://auth.server.com/realms/meu-realm',
audience: 'https://api.exemplo.com',
},
(err, decoded) => {
if (err) {
return res.status(401).json({ error: err.message });
}
(req as any).user = decoded;
next();
}
);
}PKCE em Detalhes
PKCE (Proof Key for Code Exchange — RFC 7636) foi criado para proteger o Authorization Code Flow em clients públicos (SPAs, apps mobile) que não podem guardar um client_secret.
Por que é necessário?
Sem PKCE — Ataque de interceptação:
1. App solicita authorization code
2. Código é retornado via redirect_uri
3. Malware/outro app intercepta o redirect e captura o código
4. Usa o código para obter tokens (sem precisar de client_secret)
Com PKCE — Impossível reutilizar o código:
1. App gera code_verifier (secreto, fica no app)
2. App envia apenas o hash (code_challenge) para o authorization server
3. Código interceptado é inútil — o interceptor não tem o code_verifier original
4. Apenas quem tem o code_verifier consegue trocar o código por tokensImplementação Detalhada
import os
import hashlib
import base64
import re
def generate_code_verifier(length: int = 64) -> str:
"""
RFC 7636 §4.1: code_verifier deve ter entre 43 e 128 chars
Charset: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
"""
# Gera bytes aleatórios e converte para base64url (sem padding)
token = base64.urlsafe_b64encode(os.urandom(length)).decode('utf-8')
# Remove o padding '='
return token.rstrip('=')[:128]
def generate_code_challenge(verifier: str) -> str:
"""
RFC 7636 §4.2: S256 method
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
"""
digest = hashlib.sha256(verifier.encode('ascii')).digest()
return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
# Exemplo
verifier = generate_code_verifier()
challenge = generate_code_challenge(verifier)
print(f"verifier: {verifier}")
print(f"challenge: {challenge}")
# No authorization request:
# ?code_challenge={challenge}&code_challenge_method=S256
# No token request:
# &code_verifier={verifier}Boas Práticas e Armadilhas
Boas Práticas
1. Sempre use PKCE em authorization code flow (obrigatório para public clients)
2. Valide o parâmetro "state" no callback para prevenir CSRF
3. Mantenha access tokens com vida curta (≤ 15 min)
4. Use "offline_access" scope explicitamente para receber refresh tokens
5. Implemente rotação de refresh tokens no authorization server
6. Valide iss e aud em todo token recebido pela API
7. Use discovery document (.well-known) para autodescobrir endpoints
8. Cache as chaves públicas (JWKS) — não busque a cada request
9. Implemente logout adequado: limpe tokens E encerre sessão no auth server
10. Para SPAs: access token em memória, refresh token em httpOnly cookieArmadilhas Comuns
❌ Usar Implicit Flow em SPAs novas
→ Use Authorization Code + PKCE (implicit está depreciado no OAuth 2.1)
❌ Não validar state no callback
→ Permite CSRF na autenticação
❌ Aceitar redirect_uri dinâmicos sem validação
→ Registre os redirect URIs exatos no authorization server
❌ Usar access token como id token
→ São para públicos diferentes. Access token é para a API, id token para o client.
❌ Não verificar audience (aud) na API
→ Token emitido para outro client seria aceito pela sua API
❌ Colocar client_secret em apps mobile/SPA
→ São apps públicos. Use PKCE sem client_secret.
❌ Não implementar logout no auth server
→ Usuário "deslogar" no app, mas sessão SSO continua ativa no Keycloak
❌ Fazer introspection em todo request sem cache
→ Enorme latência. Cache o resultado por alguns segundos ou use JWT local.Checklist de Implementação
Authorization Server (Keycloak):
- Realm configurado com access token lifetime ≤ 15 min
- Refresh token rotation habilitada
- Clients com redirect URIs exatos registrados
- Clients públicos sem client_secret com PKCE obrigatório
- Roles e scopes mapeados corretamente nos tokens
- HTTPS obrigatório (disallow HTTP exceto localhost)
Resource Server (API):
- Valida assinatura JWT via JWKS endpoint
- Valida
iss,aud,exp - Cache de chaves públicas (JWKS)
- Autorização granular por scope e role
- Não aceita tokens sem
Bearerprefix
Client (Frontend/App):
- PKCE implementado (code_verifier gerado com entropia suficiente)
- State validado no callback
- Access token em memória (não localStorage)
- Refresh token em httpOnly cookie (gerenciado pelo backend)
- Logout limpa tokens E encerra sessão no auth server
- Tratamento de erros de token expirado com refresh automático